diff --git a/laf-cloud/docs/client-version-update.md b/laf-cloud/docs/client-version-update.md new file mode 100644 index 0000000..7401dc7 --- /dev/null +++ b/laf-cloud/docs/client-version-update.md @@ -0,0 +1,151 @@ +# 微信小游戏强制更新 + +## 当前前端接口全部豁免 + +按当前配置要求,前端调用的 49 个后端接口名已全部加入 `versionPolicy.ts` 的 `nonPlayerFunctions`,包括 `login`、存档、活动和前端支付请求。该名单优先级最高:即使接口出现在 `apiBlacklist`、玩家命中 `openidWhitelist`、版本缺失或过低、当前策略配置无效,也会跳过版本检查,继续执行原接口鉴权和业务逻辑。登录函数内的延后检查也复用同一豁免规则。 + +清单覆盖主包和分包,以及动态选择的支付/活动入口;按真实后端函数名维护,`getAddressRankSelf` 实际请求 `getAddressRank`。`getRank`、`getSRank` 和五彩地址排行实际请求其他服务,在本文件添加对应名字不会改变那些服务的部署配置。第三方 SDK、CDN 资源请求不属于本后端云函数清单。 + +发布 `versionPolicy` 后生效。后续若要恢复某个接口的强更检查,先从 `nonPlayerFunctions` 删除该接口,再按下述版本策略配置;以下强更流程仅适用于未被豁免的接口。 + +## 当前交付与默认状态 + +- `functions/versionPolicy.ts`:人工维护版本策略,代码发布后生效,不使用数据库集合。 +- `functions/__interceptor__.ts`:Laf 全局 HTTP 云函数拦截器;白名单非空时,登录校验延后到 `login.ts` 取得微信身份后、任何账号写入之前。 +- 两个模块的 YAML 均为 `methods: []`,不要开放直接 HTTP 访问。 +- 客户端 `MatchMaster/assets/Script/module/Pay/Utils.ts` 的 POST、POSTJSON 均注入 `GameConfig.GM_INFO.version`,并统一识别强更错误。 +- 客户端 `ClientUpdate.ts` 在启动时注册微信更新管理器,在服务端要求强更时阻断游戏;普通更新不主动打断玩家。 +- 初始配置为 `currentVersion = "1.0.82"`、`force: false`、`minVersion: null`、`apiBlacklist: []`,默认不拦截任何版本。首次正式发布时,根据实际发布版本维护 GameConfig 与服务端配置。 +- 原登录回包的 `data.version = "9.9.99"` 及存档覆盖字段 `forcedUpdate` 保持原有含义,与本功能无关。 + +## 人工配置 + +```ts +// 填写当前小游戏 AppID 下的 openid。[] 表示对所有玩家执行版本检查。 +export const openidWhitelist: string[] = ["openid_of_test_player"]; + +export const currentVersion = "1.0.84"; +export const versions: Record = { + "1.0.83": { + force: true, + minVersion: "1.0.83", + apiBlacklist: ["userData", "userCoin", "cloudRise/index"], + }, + "1.0.84": { + force: false, + minVersion: "1.0.83", + apiBlacklist: ["userData", "userCoin", "cloudRise/index"], + }, +}; +``` + +这里的接口仅为示例,实际名单由维护者填写,不自动加入、推断或合并历史名单。 + +`openidWhitelist` 是同一模块中独立于 `versions` 的全局名单,适用于当前策略中的登录及黑名单接口:非空时仅名单内玩家受版本门槛限制;清空后立即恢复全量检查。未加入名单的玩家跳过全部版本门槛(包括历史最低兼容版本),后端必须仍兼容这些旧客户端。openid 精确匹配、区分大小写,不接受空字符串、空白或非字符串元素。修改后发布 `versionPolicy` 才生效,客户端无需新增 openid 请求字段。 + +登录使用微信 `code2session` 返回的真实 openid,首次注册的玩家也能命中;只交换一次登录 code。其他受限请求按现有 token 查询 `users`,未找到时查询 `usersAd`,使用数据库中的 openid;不使用客户端自报的 openid、uid 或 gameName 判断白名单。token 缺失、非法或失效时继续交给原接口鉴权,不强行提示版本更新;数据库查询失败则返回暂不可用,不绕过检查。兼容版本、非黑名单接口和空白名单模式均不额外查询身份。 + +- `currentVersion` 指向服务端正在生效的完整策略,不能按请求版本选择历史配置,也不能按对象键顺序选最新版。 +- `force` 标识该次发布是否为破坏性更新,不是取消所有历史门槛的开关。 +- `minVersion: null` 明确关闭版本门槛。普通更新应保留最低兼容版本;降低门槛需要人工修改,并确认后端仍能兼容旧数据。 +- `force: true` 时必须同时设置 `minVersion === currentVersion`;`force: false` 时可以保留较低门槛,且 `minVersion` 不能大于 `currentVersion`。 +- 版本必须为三个非负整数组成的字符串,不接受空白、前导零、预发布后缀或超出安全整数范围的分段;按数值比较,例如 `1.0.10 > 1.0.9`。 +- 接口名使用 Laf 的完整函数名,精确且区分大小写,例如 `cloudRise/index`;不要写域名、开头的 `/`、通配符或 `action`。 +- 登录固定检查,无须加入黑名单。名单中一个接口的所有 action(含读取)都会受限制。 +- 微信支付、客服等服务端回调、后台任务和管理接口不得加入玩家名单;实现对现有回调和已识别的后台入口另有排除,防止误伤。新增非玩家入口时也应检查该排除规则。 +- 配置条目不存在、版本/字段格式错误、缺少 `minVersion` 等属于错误配置,不等同于关闭。返回 `CLIENT_UPDATE_CONFIG_UNAVAILABLE`,不执行玩家业务。非玩家排除入口不受影响。 + +## 请求和响应 + +客户端 POST JSON 或表单中统一携带: + +```json +{ "version": "1.0.82", "token": "...", "action": "read" } +``` + +即使调用方自行传入 `version`,请求入口也会覆盖为 GameConfig 中的实际版本;不会覆盖调用方原对象。登录和不带 token 的公开玩家请求同样上报。旧排行榜/五彩服务的请求也上报,但那些服务的服务端拦截不在本次范围内。资源下载、微信原生支付和第三方埋点不套用玩家业务协议。 + +服务端流程: + +1. OPTIONS、内部 call 及已识别的非玩家入口放行。 +2. 获取当前服务端策略并检查配置有效性。 +3. 非 login 且不在当前黑名单的请求放行。 +4. `minVersion === null` 放行。 +5. 相等或更高版本放行;缺失、非法或低于最低兼容版本时,空白名单直接返回强更错误,非空白名单仅对服务端确认命中的玩家返回强更错误。登录在取得微信身份后执行这一步。 +6. 放行仅表示继续执行目标函数,目标函数原有鉴权和业务检查不变。 + +POST 从 `ctx.body.version` 读取;GET/HEAD 从 `ctx.query.version` 读取,不相互兜底。目标函数名取运行时 `ctx.__function_name`,兼容回退为 `ctx.request.path` 去掉首个斜杠,不接受客户端自报的函数名。 + +强更错误使用 HTTP 200 的业务 JSON(现有客户端只解析 2xx): + +```json +{ + "code": 0, + "errorCode": "CLIENT_UPDATE_REQUIRED", + "msg": "当前游戏版本过低,请更新后重新进入", + "data": { "version": "1.0.84", "minVersion": "1.0.83" } +} +``` + +配置异常使用 `code: 0`、`errorCode: "CLIENT_UPDATE_CONFIG_UNAVAILABLE"`、`data: null`、`msg: "服务暂不可用,请稍后重试"`,不触发更新界面。策略本身不从数据库读取;白名单身份查询失败也返回暂不可用。非法配置对非玩家入口之外的请求保守返回暂不可用,与是否命中白名单无关。 + +## 客户端行为 + +- 首次收到强更错误后,暂停场景输入、游戏与音频,阻止所有后续玩家业务请求。 +- 同一进程内不解除阻断;后续并发成功回包也转换为强更错误,不再进入成功业务逻辑。已经在服务器执行完的操作不会回滚,客户端不自动重放。 +- 已收到 `onUpdateReady` 时,只显示一个不可取消的“重启更新”弹窗,用户点击后调用 `applyUpdate()`。重启沿用既有自动登录流程。 +- 检查或下载中展示“正在准备更新”。等待超过 30 秒、检查无新包、下载失败或不支持更新管理器时,维持阻断,提示退出后重新进入;后续若收到 ready 仍可切换为重启提示。 +- 微信更新管理器没有主动重新下载 API,不能把重新调用 `getUpdateManager()` 当成下载重试。 +- 返回前台不会恢复游戏或绕过更新状态;原登录轮询、登录失败重试与进入 HomeScene 的异步回调都检查阻断状态。 +- 更新交互仅支持微信小游戏,使用微信原生提示和更新管理器,不提供编辑器/浏览器预览、DOM 面板或模拟开关。 + +## 发布顺序 + +1. 核实线上 Laf/Sealaf 支持双参数 `(ctx, next)` 全局拦截器。不能给 `next` 设置默认值,平台按函数形参数量选择执行方式。 +2. 发布 `versionPolicy` 的默认关闭配置,再发布 `__interceptor__`。确认现有线上拦截器没有其他逻辑需要合并,切勿直接覆盖线上独有逻辑。 +3. 发布包含上报与更新处理的新客户端。GameConfig 中版本应与实际游戏包版本一致,正式版和体验版使用同一套判断;当前项目测试/正式 URL 相同,不会按 `isDebug` 自动豁免。 +4. 真机确认新包可获取后,再发布提高门槛的 `versionPolicy`;部署完成后请求受保护接口验证新配置已生效。 +5. 后续普通更新复制有效策略,保留最低门槛和仍需要限制的接口。不要因 `force` 改为 false 而清空门槛。 + +旧包没有更新处理代码,只能被服务端拒绝,无法凭新错误码为旧包补上更新交互。启用门槛前应完成过渡版本发布。设置最低版本高于平台实际可获取的版本会让玩家停在更新提示,因此必须遵循先发布包、验证可获取、再启用门槛的顺序。 + +## 发布白名单功能 + +首次上线白名单功能需要一并发布 `versionPolicy`、`__interceptor__` 和 `login`;后续只调整名单或版本策略时发布 `versionPolicy` 即可。 + +## 与微信灰度发布的关系 + +服务端名单与微信后台灰度比例独立,不会自动同步。按本实现推导:若新版本仅向 10% 玩家发布,却把服务端最低兼容版本提高到该版本且白名单为空,另外 90% 中低于门槛的玩家在登录或访问黑名单接口时同样会被要求更新;但微信未向他们提供新包时,更新管理器无法完成更新,客户端会停在更新阻断流程中。 + +白名单只能限制“谁需要强更”,不能让名单内玩家自动取得微信灰度包。灰度期间应保持原有全量兼容门槛;要测试新门槛,使用非空白名单,并先确认这些账号实际可取得目标包。全量发布且确认目标包可获取后,再启用全量新门槛。不要用“清空白名单”表示暂停测试,因为清空意味着全量检查。 + +微信官方 [UpdateManager.applyUpdate](https://developers.weixin.qq.com/minigame/dev/api/base/update/UpdateManager.applyUpdate.html) 要求收到 `onUpdateReady` 后调用;[官方 API 类型定义](https://github.com/wechat-miniprogram/minigame-api-typings/blob/master/types/wx/lib.wx.api.d.ts) 的 UpdateManager 注释也明确新包下载由微信客户端触发,下载成功才触发 ready。这里的 90% 受影响结论来自本项目服务端规则与平台分发范围独立的推导;没有据此宣称微信会将灰度包提供给未命中玩家。 + +## 自动化验证 + +在服务端仓库运行: + +```powershell +node --test laf-cloud/tests/client-version-policy.test.mjs laf-cloud/tests/login-wucai-state.test.mjs +``` + +在 MatchMaster 仓库运行: + +```powershell +node --test tools/test-client-update.cjs tools/test-wucai-request-barrier.cjs tools/test-activity-schedule.cjs +``` + +自动测试覆盖数值版本比较、非法/缺失版本、持续门槛、黑名单精确匹配、拦截前无业务执行、回调豁免、GET/POST 参数、全入口上报、并发回包、更新就绪/失败/无更新/超时、回前台和登录重试。客户端更新测试使用不含 DOM 的微信 API 模拟环境。 + +上线前真机验证: + +- 兼容包正常自动登录;旧包的 login、黑名单接口返回强更;非黑名单接口仍按原逻辑执行。 +- 先开旧包再发布新包:新包 ready 后点击重启,重新上报新版本并自动登录。 +- 在线游戏收到强更后不能操作、不能继续发送玩家请求;切后台再回来仍阻断。 +- 断网、无新包、下载失败时不放行;恢复并重新进入后可取得新版。 +- 微信支付/客服回调和任务继续正常运行。 +- 正式版和体验版分别验证。体验版需实际上传两个包测试,模拟 API 的单元测试不能证明微信分发行为。 + +强更机制测试使用未豁免的模拟接口,覆盖版本比较、名单内外玩家、伪造 openid、GET/POST token 来源、身份查询失败和业务异常传播。登录回归验证旧版/缺失/非法版本在名单为空或非空、配置有效或无效时,仍按接口豁免规则正常登录。`login-wucai-state.test.mjs` 的既有用例 `login opens the personal CloudRise period after resolving the account` 在改动前后均失败(`null !== 1`);此外本地人工配置 `force: true, minVersion: null` 会使当前策略有效性测试失败。两项均不能表述为回归通过;当前接口豁免不会修正配置,只是跳过该校验。 + +参考:[Laf 拦截器](https://doc.laf.run/zh/cloud-function/interceptor.html)、[Laf 模块导入](https://doc.laf.run/zh/cloud-function/import.html)、[Laf 执行器](https://github.com/labring/laf/blob/main/runtimes/nodejs/src/support/engine/executor.ts)、[微信更新管理器](https://developers.weixin.qq.com/minigame/dev/api/base/update/wx.getUpdateManager.html)。 diff --git a/laf-cloud/functions/__interceptor__.ts b/laf-cloud/functions/__interceptor__.ts new file mode 100644 index 0000000..2e056f2 --- /dev/null +++ b/laf-cloud/functions/__interceptor__.ts @@ -0,0 +1,34 @@ +import cloud from "@lafjs/cloud"; +import { checkClientVersion, configUnavailable, openidWhitelist, shouldCheckPlayerVersion } from "@/versionPolicy"; + +// Laf detects this signature by function.length: keep exactly two non-default parameters. +export default async function (ctx: FunctionContext, next: (context: FunctionContext) => Promise) { + const method = String(ctx.method || ctx.request?.method || "").toUpperCase(); + if (method === "OPTIONS" || method === "CALL") return await next(ctx); + // Set by Laf's router, not by a client-provided body/header. Preserve nested function names. + const functionName = ctx.__function_name || ctx.request?.path?.replace(/^\//, ""); + if (!functionName) return configUnavailable(); + const params = method === "GET" || method === "HEAD" ? ctx.query : ctx.body; + const rejection = checkClientVersion(functionName, params?.version); + if (rejection?.errorCode === "CLIENT_UPDATE_REQUIRED" && openidWhitelist.length > 0) { + // Login has no trusted openid yet. It checks after code2session, before any user writes. + if (functionName === "login") return await next(ctx); + // Never trust a request's openid/uid for whitelist membership. Resolve the login token. + const token = params?.token; + if (typeof token !== "string" || !token) return await next(ctx); + let openid: unknown; + try { + const db = cloud.database(); + const main = await db.collection("users").where({ token }).getOne(); + const user = main.data || (await db.collection("usersAd").where({ token }).getOne()).data; + openid = user?.openid; + } catch { + // A failed lookup must not silently let a targeted old client into business logic. + return configUnavailable(); + } + // Unauthenticated requests continue to the endpoint's existing authentication checks. + if (!shouldCheckPlayerVersion(openid)) return await next(ctx); + } + if (rejection) return rejection; + return await next(ctx); +} diff --git a/laf-cloud/functions/__interceptor__.yaml b/laf-cloud/functions/__interceptor__.yaml new file mode 100644 index 0000000..93e7835 --- /dev/null +++ b/laf-cloud/functions/__interceptor__.yaml @@ -0,0 +1,4 @@ +name: __interceptor__ +desc: "玩家请求版本门禁,在业务执行前拒绝不兼容客户端" +methods: [] +tags: [] diff --git a/laf-cloud/functions/login.ts b/laf-cloud/functions/login.ts index f85860c..71a674a 100644 --- a/laf-cloud/functions/login.ts +++ b/laf-cloud/functions/login.ts @@ -1,5 +1,6 @@ // 百人赛单独上线:暂不导入黄金矿工模块或执行其查单、权益补发。 import cloud from '@lafjs/cloud' +import { checkClientVersion, openidWhitelist, shouldCheckPlayerVersion } from "@/versionPolicy"; import { createHash } from 'crypto'; import * as JungleConfigModule from "@/jungleConfig"; import { @@ -65,6 +66,14 @@ export default async function (ctx: FunctionContext) { return; } let openid = info[0]; + // Scoped rollout needs WeChat's verified identity, including accounts not created yet. + // Full rollout is already checked by the global interceptor before code2session. + const requestMethod = String(ctx.method || ctx.request?.method || "").toUpperCase(); + if (requestMethod !== "CALL" && openidWhitelist.length > 0 && shouldCheckPlayerVersion(openid)) { + const clientVersion = requestMethod === "GET" || requestMethod === "HEAD" ? ctx.query?.version : body.version; + const rejection = checkClientVersion("login", clientVersion); + if (rejection) return rejection; + } let session_key = info[1]; let unionid = info[2]; let userData = await getUserData(dbname, openid); diff --git a/laf-cloud/functions/versionPolicy.ts b/laf-cloud/functions/versionPolicy.ts new file mode 100644 index 0000000..30047dd --- /dev/null +++ b/laf-cloud/functions/versionPolicy.ts @@ -0,0 +1,117 @@ +export interface VersionPolicy { + force: boolean; + minVersion: string | null; + apiBlacklist: string[]; +} + +// Empty means all players; otherwise only these verified WeChat openids are checked. +export const openidWhitelist: string[] = [ + "o0tJD3asJusHgpSq35y7qOODjb8M", // 420418 + "o0tJD3fvbo5eBKBxJ1ZJ9l1fM_X4", // 916541 + "o0tJD3bAysG5Y3e93RikOtyLyN58", // 100057 + "o0tJD3R-js9YQIz24WGNNWepW1j0", // 1327711 +]; + +export function shouldCheckPlayerVersion(openid: unknown): boolean { + return openidWhitelist.length === 0 || (typeof openid === "string" && openidWhitelist.includes(openid)); +} + +// Publish this internal module after editing. Select the SERVER release, never the request version. +export const currentVersion = "1.0.86"; +export const versions: Record = { + "1.0.82": { force: false, minVersion: null, apiBlacklist: [] }, + "1.0.83": { force: false, minVersion: null, apiBlacklist: [] }, + "1.0.84": { force: false, minVersion: null, apiBlacklist: [] }, + "1.0.85": { force: false, minVersion: null, apiBlacklist: [] }, + "1.0.86": { force: false, minVersion: null, apiBlacklist: [] }, + "1.0.87": { force: false, minVersion: null, apiBlacklist: [] } +}; + +// Explicit version-check exemptions, including all currently called frontend APIs. +// Entries here take precedence over apiBlacklist, openidWhitelist and policy validation. +const nonPlayerFunctions = new Set([ + // 微信支付相关 + "wx/payCallBack", "wx/iosPayCall", "wx/KeFuInfo", "wx/getOrderReward", "wx/getPayInfo", "wx/iosgetPayInfo", "wx/iosorderPaySig", "wx/orderPaySig", + "suRecall", + // 黄金矿工 + "goldMiner/merchantNotify", "goldMiner/index", + // 用户付费统计 + "rechargeStats", "repairPayUser", + "clearPay", "clearSrank", "clearPHBDataById", "creatCdk", "creatCDkTwo", + "searchOrder", "searchPlayerDataByLevel", + // Frontend API names after URL routing (getAddressRankSelf -> getAddressRank). + "activityConfig/list", "address", "canRebornGift", "cloudRise/index", "comment", + // "dayTaskInfo", + "drawCat", "drawHead", "getAddressRank", "getFilm", "getHeadInfo", + "getRank", "getSRank", "getShareLv", "getTaskReward", "getUserInfoByID", + "jungleTreasure", "limitedTimeEvent", + // "login", + "miniProgramWelfare", + "monthGetReward", "monthlyCard", "passCheck", "passCheckLv", "passCheckSettlement", + "passCheckUpgrade", "pyramid/api", "rebornGift", "redeem", "setCatArr", + "setForcedUpdate", "setUserLevel", "setWinStreak", "shareLvSuccess", "shopDouble", + "signInActivityInfo", "userCoin", "userData", "userHealth", "userLevel", "userPower", + "userProp", "wucaiTransfer", +]); + +export function isNonPlayerFunction(name: string): boolean { + return name.startsWith("__") || nonPlayerFunctions.has(name) + || /(?:Admin|Jobs)$/.test(name) || /(?:^|\/)(?:admin|jobs|testPeriods)(?:\/|$)/.test(name); +} + +function parseVersion(value: unknown): number[] | null { + if (typeof value !== "string" || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value)) return null; + const parts = value.split(".").map(Number); + return parts.every(Number.isSafeInteger) ? parts : null; +} + +/** null means malformed; otherwise -1 / 0 / 1 (numeric components, not string ordering). */ +export function compareVersions(left: unknown, right: unknown): number | null { + const a = parseVersion(left), b = parseVersion(right); + if (!a || !b) return null; + for (let index = 0; index < 3; index++) { + if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1; + } + return 0; +} + +export function configUnavailable() { + return { code: 0, errorCode: "CLIENT_UPDATE_CONFIG_UNAVAILABLE", data: null, msg: "服务暂不可用,请稍后重试" }; +} + +export function validatePolicy(release: string, policy: VersionPolicy): boolean { + if (!parseVersion(release) || !policy || typeof policy.force !== "boolean" + || !Array.isArray(policy.apiBlacklist) + || !policy.apiBlacklist.every(name => typeof name === "string" && /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(name))) return false; + if (policy.minVersion === null) return !policy.force; + const compared = compareVersions(policy.minVersion, release); + return compared !== null && compared <= 0 && (!policy.force || compared === 0); +} + +/** Returns null to continue normal authentication/business logic, otherwise a business error. */ +export function checkClientVersion( + functionName: string, + clientVersion: unknown, + release = currentVersion, + policies: Record = versions, +) { + if (isNonPlayerFunction(functionName)) return null; + if (!Array.isArray(openidWhitelist) + || !openidWhitelist.every(openid => typeof openid === "string" && /^[A-Za-z0-9_-]+$/.test(openid))) return configUnavailable(); + const policy = policies && Object.prototype.hasOwnProperty.call(policies, release) ? policies[release] : null; + if (!validatePolicy(release, policy)) return configUnavailable(); + if (functionName !== "login" && !policy.apiBlacklist.includes(functionName)) return null; + if (policy.minVersion === null) return null; + const compared = compareVersions(clientVersion, policy.minVersion); + if (compared !== null && compared >= 0) return null; + return { + code: 0, + errorCode: "CLIENT_UPDATE_REQUIRED", + msg: "当前游戏版本过低,请更新后重新进入", + data: { version: release, minVersion: policy.minVersion }, + }; +} + +export default async function (_ctx: FunctionContext) { + return { data: 'hi, laf' } +} diff --git a/laf-cloud/functions/versionPolicy.yaml b/laf-cloud/functions/versionPolicy.yaml new file mode 100644 index 0000000..854b5c5 --- /dev/null +++ b/laf-cloud/functions/versionPolicy.yaml @@ -0,0 +1,4 @@ +name: versionPolicy +desc: "客户端版本策略(内部模块,人工维护后发布)" +methods: [] +tags: [] diff --git a/laf-cloud/tests/client-version-policy.test.mjs b/laf-cloud/tests/client-version-policy.test.mjs new file mode 100644 index 0000000..2bd16a8 --- /dev/null +++ b/laf-cloud/tests/client-version-policy.test.mjs @@ -0,0 +1,180 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { currentVersion, versions, checkClientVersion, compareVersions, validatePolicy, openidWhitelist, shouldCheckPlayerVersion } from '../functions/versionPolicy.ts'; + +const identity = { users: [], usersAd: [], reads: 0, fail: false }; +globalThis.__versionCloud = { database: () => ({ collection: name => ({ where: query => ({ + async getOne() { + identity.reads++; + if (identity.fail) throw new Error('database unavailable'); + assert.equal(typeof query.token, 'string'); + return { data: identity[name].find(user => user.token === query.token) || null }; + }, +}) }) }) }; + +const hooks = registerHooks({ resolve(specifier, context, next) { + if (specifier === '@/versionPolicy') return { url: new URL('../functions/versionPolicy.ts', import.meta.url).href, shortCircuit: true }; + if (specifier === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__versionCloud', shortCircuit: true }; + return next(specifier, context); +} }); +const { default: interceptor } = await import('../functions/__interceptor__.ts'); +hooks.deregister(); +const policy = (overrides = {}) => ({ force: false, minVersion: '1.0.83', apiBlacklist: ['gatedPlayer', 'gatedPlayerData', 'gatedPlayer/index'], ...overrides }); +const check = (name, version, overrides = {}) => checkClientVersion(name, version, '1.0.84', { '1.0.84': policy(overrides) }); + +test('checked-in current policy is valid and an explicitly disabled release accepts legacy clients', () => { + assert.equal(validatePolicy(currentVersion, versions[currentVersion]), true); + const disabled = { '1.0.82': policy({ minVersion: null, apiBlacklist: [] }) }; + assert.equal(checkClientVersion('gatedPlayer', undefined, '1.0.82', disabled), null); +}); + +test('numeric comparison handles component boundaries and rejects ambiguous or malformed versions', () => { + for (const [a, b, result] of [['1.0.10', '1.0.9', 1], ['1.0.83', '1.0.83', 0], ['1.9.99', '2.0.0', -1], ['1.0.99', '1.1.0', -1]]) { + assert.equal(compareVersions(a, b), result); + } + for (const value of [undefined, null, 123, '', '1.0', '1.0.83-beta', ' 1.0.83', '01.0.83', ['1.0.83'], {}, '9007199254740992.0.0']) { + assert.equal(compareVersions(value, '1.0.83'), null); + assert.equal(check('gatedPlayer', value).errorCode, 'CLIENT_UPDATE_REQUIRED'); + } +}); + +test('ordinary releases retain the minimum even when force is false; future versions are accepted', () => { + for (const name of ['gatedPlayer', 'gatedPlayerData', 'gatedPlayer/index']) { + const denied = check(name, '1.0.82'); + assert.equal(denied.errorCode, 'CLIENT_UPDATE_REQUIRED'); + assert.deepEqual(denied.data, { version: '1.0.84', minVersion: '1.0.83' }); + for (const version of ['1.0.83', '1.0.84', '2.0.0']) assert.equal(check(name, version), null); + } + assert.equal(check('userCoin', undefined), null, 'unlisted endpoints remain outside the gate'); + assert.equal(check('gatedPlayer/indexExtra', undefined), null, 'blacklist matching is exact'); + assert.equal(check('gatedPlayer', undefined, { apiBlacklist: [] }), null); +}); + +test('server release selects the policy; client version cannot select an old or missing policy', () => { + const policies = { '1.0.82': policy({ minVersion: null }), '1.0.84': policy() }; + assert.equal(checkClientVersion('gatedPlayer', '1.0.82', '1.0.84', policies).errorCode, 'CLIENT_UPDATE_REQUIRED'); + assert.equal(checkClientVersion('gatedPlayer', '9.9.99', '1.0.84', policies), null); +}); + +test('invalid current configuration fails closed as service unavailable, never as upgrade required', () => { + for (const override of [ + { force: true }, { minVersion: '2.0.0' }, { minVersion: undefined }, { minVersion: '1.0' }, + { force: 'false' }, { force: true, minVersion: null }, { apiBlacklist: null }, { apiBlacklist: ['/userData'] }, + ]) assert.equal(check('gatedPlayer', '9.9.99', override).errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE'); + assert.equal(check('gatedPlayer', '1.0.83', { force: true, minVersion: '1.0.84' }).errorCode, 'CLIENT_UPDATE_REQUIRED'); + assert.equal(checkClientVersion('gatedPlayer', '1.0.83', 'missing', {}).errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE'); + assert.equal(check('gatedPlayer', undefined, { minVersion: null }), null); +}); + +test('server callbacks, jobs, and administrative functions remain outside player checks', () => { + for (const name of ['wx/payCallBack', 'wx/iosPayCall', 'wx/KeFuInfo', 'goldMiner/merchantNotify', 'wxPerformanceCall', + 'suRecall', 'passCheckJobs', 'pyramid/jobs', 'cloudRise/admin', 'abConfigAdmin', 'signInTestAdmin', '__init__']) { + assert.equal(check(name, undefined, { apiBlacklist: [name] }), null); + assert.equal(checkClientVersion(name, undefined, 'bad', {}), null, 'even invalid policy must not stop server callbacks'); + } +}); + +test('Laf interceptor blocks before invoking business logic and preserves next response/context', async () => { + assert.equal(interceptor.length, 2, 'Laf selects middleware semantics by parameter count'); + const original = versions[currentVersion]; + versions[currentVersion] = policy({ minVersion: currentVersion }); + let calls = 0; + const next = async ctx => { calls++; return { business: true, context: ctx }; }; + try { + for (const name of ['gatedPlayer', 'gatedPlayerData', 'gatedPlayer/index']) { + const response = await interceptor({ __function_name: name, method: 'POST', body: { version: '1.0.1', action: 'read' } }, next); + assert.equal(response.errorCode, 'CLIENT_UPDATE_REQUIRED'); + } + assert.equal(calls, 0, 'no business side effects before an upgrade'); + const ctx = { __function_name: 'gatedPlayerData', method: 'POST', body: { version: currentVersion } }; + const response = await interceptor(ctx, next); + assert.equal(response.context, ctx); + assert.equal(calls, 1); + await interceptor({ __function_name: 'userCoin', method: 'POST', body: {} }, next); + await interceptor({ __function_name: 'gatedPlayer', method: 'OPTIONS' }, next); + await interceptor({ __function_name: 'gatedPlayer', method: 'call' }, next); + assert.equal(calls, 4); + } finally { versions[currentVersion] = original; } +}); + +test('uses runtime target name and correct body/query source, with nested path fallback', async () => { + const original = versions[currentVersion]; + versions[currentVersion] = policy({ minVersion: currentVersion }); + const next = async () => ({ passed: true }); + try { + const blocked = await interceptor({ __function_name: 'gatedPlayer', method: 'POST', body: { __function_name: 'unlisted' }, query: { version: currentVersion } }, next); + assert.equal(blocked.errorCode, 'CLIENT_UPDATE_REQUIRED'); + assert.deepEqual(await interceptor({ __function_name: 'gatedPlayer', method: 'GET', query: { version: currentVersion } }, next), { passed: true }); + assert.equal((await interceptor({ request: { path: '/gatedPlayer/index', method: 'POST' }, body: {} }, next)).errorCode, 'CLIENT_UPDATE_REQUIRED'); + assert.equal((await interceptor({ method: 'POST', body: {} }, next)).errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE'); + } finally { versions[currentVersion] = original; } +}); + +test('policy module and interceptor have no direct HTTP methods', () => { + for (const name of ['versionPolicy', '__interceptor__']) { + const yaml = readFileSync(new URL(`../functions/${name}.yaml`, import.meta.url), 'utf8'); + assert.match(yaml, /methods: \[\]/); + } +}); + +test('openid whitelist is exact, empty means all players, and malformed entries fail closed', () => { + const original = [...openidWhitelist]; + try { + openidWhitelist.splice(0); + assert.equal(shouldCheckPlayerVersion(undefined), true); + openidWhitelist.push('openid-A'); + assert.equal(shouldCheckPlayerVersion('openid-A'), true); + for (const openid of ['openid-a', 'other', undefined, { $ne: null }]) { + assert.equal(shouldCheckPlayerVersion(openid), false); + } + for (const invalid of ['', ' openid-A', null, 123]) { + openidWhitelist.splice(0, openidWhitelist.length, invalid); + assert.equal(check('gatedPlayer', '1.0.82').errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE'); + assert.equal(check('wx/payCallBack', undefined), null); + } + } finally { openidWhitelist.splice(0, openidWhitelist.length, ...original); } +}); + +test('scoped interceptor resolves tokens for nonexempt APIs and never trusts claimed openid', async () => { + const original = versions[currentVersion], originalWhitelist = [...openidWhitelist]; + versions[currentVersion] = policy({ minVersion: currentVersion }); + openidWhitelist.splice(0, openidWhitelist.length, 'openid-target'); + identity.users = [{ token: 'target-token', openid: 'openid-target' }, { token: 'other-token', openid: 'openid-other' }]; + identity.usersAd = [{ token: 'ad-token', openid: 'openid-target' }]; + identity.reads = 0; + let calls = 0; + const next = async () => { calls++; return { passed: true }; }; + const post = (body = {}, name = 'gatedPlayerData') => interceptor({ __function_name: name, method: 'POST', body: { version: '1.0.1', ...body } }, next); + try { + assert.deepEqual(await post({ openid: 'openid-target' }, 'login'), { passed: true }); + assert.equal(identity.reads, 0, 'exempt login needs no identity lookup in interceptor'); + calls = 0; + for (const token of ['target-token', 'ad-token']) { + assert.equal((await post({ token, openid: 'other', uid: 'forged', gameName: 'fake' })).errorCode, 'CLIENT_UPDATE_REQUIRED'); + } + assert.equal(calls, 0, 'targeted old requests cannot enter business code'); + for (const token of ['other-token', 'unknown', '', undefined, { $ne: null }]) { + assert.deepEqual(await post({ token, openid: 'openid-target' }), { passed: true }); + } + assert.equal((await interceptor({ __function_name: 'gatedPlayerData', method: 'GET', query: { version: '1.0.1', token: 'target-token' }, body: { token: 'other-token' } }, next)).errorCode, 'CLIENT_UPDATE_REQUIRED'); + const beforeReads = identity.reads; + await post({ token: 'target-token', version: currentVersion }); + await post({ token: 'target-token' }, 'userCoin'); + await post({ token: 'target-token' }, 'wx/payCallBack'); + assert.equal(identity.reads, beforeReads, 'compatible/unlisted/callback requests need no identity lookup'); + const businessError = new Error('business error'); + await assert.rejects(interceptor({ __function_name: 'gatedPlayerData', method: 'POST', body: { token: 'other-token', version: '1.0.1' } }, async () => { throw businessError; }), error => error === businessError); + identity.fail = true; + const beforeCalls = calls; + assert.equal((await post({ token: 'target-token' })).errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE'); + assert.equal(calls, beforeCalls, 'identity lookup failure cannot bypass the gate'); + openidWhitelist.splice(0); + assert.equal((await post()).errorCode, 'CLIENT_UPDATE_REQUIRED', 'empty list restores full rollout without identity lookup'); + } finally { + identity.fail = false; + versions[currentVersion] = original; + openidWhitelist.splice(0, openidWhitelist.length, ...originalWhitelist); + } +}); diff --git a/laf-cloud/tests/login-wucai-state.test.mjs b/laf-cloud/tests/login-wucai-state.test.mjs index dda5e79..a3f599f 100644 --- a/laf-cloud/tests/login-wucai-state.test.mjs +++ b/laf-cloud/tests/login-wucai-state.test.mjs @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { registerHooks } from "node:module"; import { isDeepStrictEqual } from "node:util"; +import { currentVersion, versions, openidWhitelist } from "../functions/versionPolicy.ts"; const functionUrl = new URL("../functions/login.ts", import.meta.url); const dailyTaskConfigUrl = new URL("../functions/dailyTaskConfig.ts", import.meta.url); @@ -113,6 +114,7 @@ globalThis.__loginWucaiCloudMock = { }, }, async fetch(url) { + state.wxCalls = (state.wxCalls || 0) + 1; const code = new URL(url).searchParams.get("js_code") || "code-1"; const suffix = code.replace("code-", ""); return { @@ -145,6 +147,7 @@ registerHooks({ return next(url, url.endsWith('.ts') ? { ...context, format: 'module-typescript' } : context); }, resolve(specifier, context, nextResolve) { + if (specifier === "@/versionPolicy") return { url: new URL("../functions/versionPolicy.ts", import.meta.url).href, shortCircuit: true }; assert(!specifier.startsWith("@/goldMiner/") && !specifier.startsWith("@/dailyTask"), "CloudRise-only login must load without gold miner or daily task V2 modules"); if (specifier === "@/cloudRise/periods") return {url:"data:text/javascript,export async function ensureOnLogin(uid) {globalThis.__cloudRiseLoginUid = uid;}",shortCircuit:true}; @@ -182,8 +185,10 @@ registerHooks({ }, }); const { default: login } = await import(functionUrl); +const { default: versionInterceptor } = await import(new URL("../functions/__interceptor__.ts", import.meta.url)); function reset(users = [], source = null) { + state.wxCalls = 0; globalThis.__goldMinerLogin.recovered = null; globalThis.__goldMinerLogin.calls = []; globalThis.__goldMinerLogin.fail = false; @@ -944,3 +949,27 @@ test("first auto import does not overwrite a target that gained resources concur assert.equal(state.users[0].coinAmount, 25); assert.equal(state.users[0].wucai_type, -1); }); + +test("exempt login accepts old clients with and without an openid whitelist, even with invalid policy", async () => { + const original = versions[currentVersion], originalWhitelist = [...openidWhitelist]; + try { + for (const whitelist of [[], ["openid-1"], ["openid-other"]]) { + openidWhitelist.splice(0, openidWhitelist.length, ...whitelist); + for (const minVersion of [currentVersion, null]) { + versions[currentVersion] = { force: true, minVersion, apiBlacklist: ["login"] }; + for (const version of [undefined, "invalid", "1.0.1"]) { + reset(); + const result = await versionInterceptor({ __function_name: "login", method: "POST", body: { + code: "code-1", version, + }, headers: {}, socket: {} }, login); + assert.equal(result.code, 1, "login exemption wins over the blacklist, whitelist and policy validation"); + assert.equal(state.users[0].openid, "openid-1"); + assert.equal(state.wxCalls, 1, "normal login exchanges the code once"); + } + } + } + } finally { + versions[currentVersion] = original; + openidWhitelist.splice(0, openidWhitelist.length, ...originalWhitelist); + } +});