feat(platform/P1): 配对码跨设备 + 认领打磨 + 键盘语音双套资产(两 agent 并行 + 集成收口)

配对码与认领(B):
- POST /api/pair/start|join:6 位码 10 分钟单次有效、仅存内存零泄露面、5 连错 per-IP 封锁 10 分钟;join=新设备档案按桶 LWW 并入老档案+token 改绑(复用 claim-merge);日志脱敏
- 大厅:生成配对码(speechSynthesis 逐位朗读+aria-live+重听)/从其他设备转入(one-time-code 输入框)/从本机移除/401 过期自愈引导/aria 补全/玩家名可听化
- api-smoke 12/12(配对全流程/频控/封锁/过期自愈/移除/digitsToCn)+ CDP 真浏览器 E2E 22/22

键盘语音双套资产(A,路线 a):
- 164 变体句(8 游戏 grep 实测,超出调研预估 153:含轻触/点两下/按住变体)×2 性别=328 生成 656 文件 36.9 分钟;触屏主资产零字节改动(PCM 逐样本比对后还原)
- 引擎 _candidates 三跳路由 key→gender→平铺(inputMode 跟随);缓存键 mode|gender|id;engine-input 新增 K9 路由断言(72 条)
- check_all_assets 增 key 列(两性别一致且 ⊆ 主集)8 游戏全绿;voice_text.json 增 __key__ 表;live control.mjs key 会话对照键盘措辞验收(集成)
- voice-ids 文档:8 游戏键盘变体小节由 KEY_LINES 程序化生成(与台词表逐字断言一致);补齐 storyline/escape/simon 三个历史缺失文档;design README 增 new8 索引
- mock 适配:playtest persona 加 maxTouchPoints=1 保持触屏链断言语义零改动

回归:12 套件全绿(8 playtest + guide 122 + engine-voice 19 + engine-input 72 + api-smoke 12/12)
同批入库:批次 2/3 已完稿设计文档(17-abyss/18-detective/19-cipher 全套,20-22 在途)
This commit is contained in:
xsl
2026-09-10 00:43:23 +08:00
parent ede522dda1
commit 566241e05d
694 changed files with 4242 additions and 168 deletions
+39 -2
View File
@@ -7,6 +7,10 @@ P0-B 起采用**无感档案系统**(设计见 `research/platform/01-identity.
认领(命名 + 设 PIN)完全可选,同设备多玩家靠大厅档案切换器隔离,进度按游戏分桶、
服务器时钟裁决的桶级 LWW 同步。8 个游戏目录零改动——分桶/倒灌发生在大厅层。
P1 增补**配对码跨设备**(`01 §4.2` 旅程 3):老设备在「档案管理 → 转移到新设备」
生成 6 位配对码(逐位朗读),新设备输入即可把本机进度按桶 LWW 合并进老档案;
token 180 天滑动过期,到期后大厅引导「重新登录或新建」。
## 启动
```bash
@@ -30,6 +34,9 @@ PORT=8001 SG_DATA_DIR=/tmp/sg-data tools/node/bin/node web/server/server.js
- PIN 以 **scrypt 哈希**(N=16384, r=8, node:crypto 内置)存储,不再明文;
- token **180 天滑动过期**、可 logout 吊销、惰性清扫,不再无限增长;
- 匿名建档端点有 per-IP 频控(默认 10 个/天,`SG_DEVICE_LIMIT` 覆盖);
- 配对码:**仅存内存不落盘、不进日志**;10 分钟单次有效、同档案同时仅 1 个有效码、
`pair/start` per-IP 频控(默认 20 个/天,`SG_PAIR_LIMIT` 覆盖)、`pair/join`
**连错 5 次封锁同 IP 10 分钟**(10^6 码空间 × 5 次 → 单窗口猜中概率 ≤ 5×10⁻⁶);
- 仍是 HTTP 明文传输、无 TLS、无登录失败退避——威胁模型是「家庭 WiFi 可信 +
防家人恶作剧」。公网开放 API 的前置清单见 `docs/deploy.md`「以后若要开放用户系统」。
@@ -81,8 +88,9 @@ PORT=8001 SG_DATA_DIR=/tmp/sg-data tools/node/bin/node web/server/server.js
### POST /api/login —— 旧客户端兼容
- 请求:`{ "username": "uuu", "pin": "0858" }`
- 成功:`200 { "ok": true, "token": "…", "username": "uuu", "profileId": "uuu", "name": "uuu" }`
- 成功:`200 { "ok": true, "token": "…", "username": "uuu", "profileId": "uuu", "name": "uuu", "claimed": true }`
- v1 用户迁移后照常可登录;若存量 PIN 尚为明文,验证成功时**透明升级**为 scrypt 哈希。
大厅在 token 180 天过期后的「名字 + PIN 重新登录」路径也走本端点(无需有效 token)。
### POST /api/register —— 旧客户端兼容(建议改用 device + claim)
@@ -93,6 +101,33 @@ PORT=8001 SG_DATA_DIR=/tmp/sg-data tools/node/bin/node web/server/server.js
- 请求头:Bearer;成功:`200 { "ok": true }`
### POST /api/pair/start —— 生成配对码(老设备出示,P1)
- 请求头:Bearer(匿名 / 已认领档案均可)
- 请求:`{}`
- 成功:`200 { "ok": true, "code": "483920", "expiresAt": "2026-09-09T12:10:00Z" }`
- `code`:6 位数字,**10 分钟有效、单次使用**;同档案同时仅 1 个有效码,
重新生成会立即使旧码作废(单码作废 + 重生成即调用本端点)
- 码仅存服务器内存:进程重启即全部失效(老设备再点一次「生成配对码」即可)
- 失败:`401` / `429`(同 IP 当日生成数超 `SG_PAIR_LIMIT`,默认 20)
### POST /api/pair/join —— 配对码转入(新设备,P1)
- 请求:`{ "code": "483920" }`;请求头 Bearer 可选(新设备当前匿名档案 token)。
token 无效/过期时按「无档案设备」处理,不报 401。
- 成功(带 token,与 claim-merge 同语义——**新设备当前档案按桶 LWW 并入码对应的
老档案**,方向裁定:本地新进度并入老档案):
`200 { "ok": true, "merged": true, "mergedGames": 2, "profileId": "p_…", "name": "玩家一", "token": "<原 token,已改绑>", "claimed": false }`
- 成功(无 token,全新浏览器):直接为老档案签发新 token(`merged: false`)
- 成功(joiner 已是该档案):幂等返回当前 token(`merged: false`),码照常作废
- 成功即**作废该码**(单次使用),并发重复 join 靠同步删除保证原子
- 失败:
- `400`(码格式非法,不计入连错)
- `404`(码不存在 / 已过期 / 已用过——统一文案不泄露状态;**计入连错**)
- `409`(joiner 当前档案已设 PIN:请先「+ 新玩家」建匿名档案再转入;不消费码)
- `429`(同 IP 连错 5 次 → 封锁 10 分钟)
- 日志脱敏:配对码明文不进日志(与 PIN 同待遇)
### GET /api/progress —— 读进度
- 单桶(旧形状,游戏兼容,如 radio 直接读 `data`):
@@ -153,4 +188,6 @@ tools/node/bin/node web/server/test/api-smoke.mjs # API 冒烟 + 大厅分桶
```
覆盖:device 建档/重附着、进度 PUT-GET-LWW 冲突(含 legacy 放行)、claim 命名/设 PIN/
合并、旧用户登录迁移、per-IP 频控、/server/ 静态封禁、localStorage 分桶逻辑(H1-H4)。
合并、旧用户登录迁移、per-IP 频控、/server/ 静态封禁、localStorage 分桶逻辑(H1-H6)、
配对码全流程(I1:start 规约/join 桶级 LWW 合并/单次作废/无 token 转入/幂等/日志脱敏)、
配对码防护(I2:join 5 连错封锁、start per-IP 频控)、token 过期 401 路径(J1)。
+200 -21
View File
@@ -17,6 +17,14 @@
* - POST /api/login {username, pin} 旧客户端兼容(v1 用户迁移后仍可登录)
* - POST /api/register {username, pin} 旧客户端兼容(等价于直接建已认领档案,建议改用 claim)
* - POST /api/logout 吊销当前 token(Bearer)
* - POST /api/pair/start (Bearer,匿名/已认领均可)生成 6 位配对码:
* 10 分钟有效、单次使用、同档案同时仅 1 个有效码
* (重新生成即作废旧码)、per-IP 频控(SG_PAIR_LIMIT,默认 20/天)
* - POST /api/pair/join {code} 新设备转入:把新设备当前档案按桶 LWW 合并进码对应
* 的老档案并改绑 token(与 claim-merge 同语义);无 token
* 时直接为老档案签发新 token。join 连错 5 次 per-IP 封锁
* 10 分钟;成功后码立即作废(单次使用)。配对码仅存内存,
* 不落盘、不进日志。
* - GET /api/progress?game=xxx 读单桶,保持 {game, data} 形状(radio 等游戏兼容)
* - GET /api/progress 读全部桶 {games: {...}}(大厅一次拉回,修 03 §1.2)
* - PUT /api/progress {game, data, baseUpdatedAt?}
@@ -34,7 +42,8 @@
* <username>.json {game: data} → profiles/<username>.json(每桶补 updatedAt/rev)
*
* 安全水位(家庭局域网):PIN scrypt 哈希(N=16384, r=8)、token 180 天滑动过期 +
* 登出吊销 + 惰性清扫、/api/device per-IP 频控(默认 10 个/天,env SG_DEVICE_LIMIT)。
* 登出吊销 + 惰性清扫、/api/device per-IP 频控(默认 10 个/天,env SG_DEVICE_LIMIT)、
* /api/pair/* per-IP 频控 + join 5 连错封锁 + 配对码日志脱敏。
* HTTP 明文传输不变——请勿暴露公网,详见 web/server/README.md。
*/
@@ -56,6 +65,10 @@ const USERS_FILE = path.join(DATA_DIR, 'users.json'); // v1 兼容
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json'); // v1 兼容
const DEVICE_LIMIT = Number(process.env.SG_DEVICE_LIMIT) || 10; // per-IP 每日匿名建档上限
const PAIR_START_LIMIT = Number(process.env.SG_PAIR_LIMIT) || 20; // per-IP 每日配对码签发上限
const PAIR_TTL_MS = 10 * 60 * 1000; // 配对码有效期:10 分钟
const PAIR_MAX_FAILS = 5; // join 连错封锁阈值(码尝试错误 5 次 → 封锁,旧设备重新生成)
const PAIR_BLOCK_MS = 10 * 60 * 1000; // join 连错封锁时长
const TOKEN_TTL_MS = 180 * 24 * 3600 * 1000; // 180 天滑动
const TOKEN_TOUCH_MS = 6 * 3600 * 1000; // 滑动续期节流(6h 内不重复落盘)
@@ -83,6 +96,7 @@ const MIME = {
const USERNAME_RE = /^[A-Za-z0-9_\-一-龥]{1,24}$/;
const PIN_RE = /^\d{4,6}$/;
const TOKEN_RE = /^[0-9a-f]{16,128}$/i;
const PAIR_CODE_RE = /^\d{6}$/;
// ---------------- 状态 ----------------
@@ -256,6 +270,101 @@ function findClaimedByName(name) {
return null;
}
// ---------------- 跨档案合并原语(claim-merge 与 pair/join 共用,03 §3.4) ----------------
/** 把 srcPid 的进度按桶 LWW 并入 targetPid,返回更新的桶数。 */
function mergeGamesInto(targetPid, srcPid) {
let mergedGames = 0;
return withGamesDoc(targetPid, (dst) =>
loadGamesDoc(srcPid).then((src) => {
for (const [game, bucket] of Object.entries(src.games)) {
const cur = dst.games[game];
if (!cur || newerBucket(cur, bucket) !== cur) {
dst.games[game] = bucket;
mergedGames += 1;
}
}
}),
).then(() => mergedGames);
}
/** 源档案出索引 + 惰性删除其进度文件(claim-merge / pair-join 收尾共用)。 */
async function retireProfile(pid) {
delete profileIndex.profiles[pid];
await persistProfileIndex();
enqueueWrite(profileGamesFile(pid), async () => {
await fsp.unlink(profileGamesFile(pid)).catch(() => {});
});
}
// ---------------- 配对码(P1:跨设备转移,01 §4.2 旅程 3) ----------------
// 仅存内存:10 分钟短票据,进程重启即失效(老设备重新生成即可),不落盘不进日志。
/** @type {Map<string, {profileId: string, createdAt: string, expiresAt: string}>} code -> entry */
const pairings = new Map();
/** 同档案同时仅 1 个有效码:profileId -> code */
const profilePairCode = new Map();
/** join 连错封锁:ip -> {fails, blockedUntil} */
const pairJoinFails = new Map();
/** pair/start per-IP 频控:ip -> {day, count} */
const pairStarts = new Map();
function sweepPairings() {
const now = Date.now();
for (const [code, e] of pairings) {
if (!profileIndex.profiles[e.profileId] || Date.parse(e.expiresAt) < now) {
pairings.delete(code);
if (profilePairCode.get(e.profileId) === code) profilePairCode.delete(e.profileId);
}
}
}
/** 作废一个码(join 成功消费或重新生成顶替)。 */
function consumePairing(code) {
const e = pairings.get(code);
pairings.delete(code);
if (e && profilePairCode.get(e.profileId) === code) profilePairCode.delete(e.profileId);
}
function clientIp(req) {
return String(req.socket.remoteAddress || '?').replace(/^::ffff:/, '');
}
function allowPairStart(req) {
const ip = clientIp(req);
const day = new Date().toISOString().slice(0, 10);
const rec = pairStarts.get(ip);
if (!rec || rec.day !== day) {
pairStarts.set(ip, { day, count: 1 });
return true;
}
if (rec.count >= PAIR_START_LIMIT) return false;
rec.count += 1;
return true;
}
function pairJoinBlocked(req) {
const rec = pairJoinFails.get(clientIp(req));
return !!(rec && rec.blockedUntil > Date.now());
}
/** join 一次失败(码不存在/已用/过期)计一次;连错 PAIR_MAX_FAILS 次封锁 PAIR_BLOCK_MS。 */
function registerPairJoinFail(req) {
const ip = clientIp(req);
let rec = pairJoinFails.get(ip);
if (!rec) {
rec = { fails: 0, blockedUntil: 0 };
pairJoinFails.set(ip, rec);
}
if (rec.blockedUntil > Date.now()) return; // 封锁期内已拒之门外,不再累计
rec.fails += 1;
if (rec.fails >= PAIR_MAX_FAILS) {
rec.fails = 0;
rec.blockedUntil = Date.now() + PAIR_BLOCK_MS;
console.log(`[server] pair/join 连错 ${PAIR_MAX_FAILS} 次: ip=${ip} 封锁 ${PAIR_BLOCK_MS / 60000} 分钟(码作废需老设备重新生成)`);
}
}
/** 自动名:玩家一、玩家二 … 玩家九十九(预生成语音词表覆盖一~九,见 01 §4.2)。 */
function cnNum(n) {
const d = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
@@ -271,7 +380,7 @@ function cnNum(n) {
const deviceCreations = new Map(); // ip -> {day, count}
function allowDeviceCreate(req) {
const ip = String(req.socket.remoteAddress || '?').replace(/^::ffff:/, '');
const ip = clientIp(req);
const day = new Date().toISOString().slice(0, 10);
const rec = deviceCreations.get(ip);
if (!rec || rec.day !== day) {
@@ -430,32 +539,100 @@ async function handleApi(req, res, url) {
target.pinHash = hashPin(pin); // v1 明文透明升级(迁移漏网兜底)
}
const srcPid = auth.profileId;
let mergedGames = 0;
await withGamesDoc(targetPid, (dst) => {
// 源/目标两文件各自串行;合并按桶 LWW(newerBucket),重复执行幂等
return loadGamesDoc(srcPid).then((src) => {
for (const [game, bucket] of Object.entries(src.games)) {
const cur = dst.games[game];
if (!cur || newerBucket(cur, bucket) !== cur) {
dst.games[game] = bucket;
mergedGames += 1;
}
}
});
});
const mergedGames = await mergeGamesInto(targetPid, srcPid);
tokens[auth.token].profileId = targetPid; // token 改绑目标档案
await persistTokens();
delete profileIndex.profiles[srcPid]; // 匿名源档案出索引(其 games 文件成孤儿,惰性清理见下)
await persistProfileIndex();
enqueueWrite(profileGamesFile(srcPid), async () => {
await fsp.unlink(profileGamesFile(srcPid)).catch(() => {});
});
await retireProfile(srcPid); // 匿名源档案出索引(进度已并入,文件惰性清理)
console.log(`[server] claim 合并: ${srcPid} → ${targetPid}(${mergedGames} 桶更新)`);
return sendJson(res, 200, {
ok: true, merged: true, mergedGames, profileId: targetPid, name: target.name, claimed: true,
});
}
// ---- 配对码:老设备出示(Bearer,匿名/已认领均可) ----
if (route === 'POST /api/pair/start') {
const auth = authProfile(req);
if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' });
sweepPairings();
if (!allowPairStart(req)) {
return sendJson(res, 429, { error: `本机今日配对码生成数已达上限(${PAIR_START_LIMIT})` });
}
// 同档案同时仅 1 个有效码:重新生成即作废旧码
const old = profilePairCode.get(auth.profileId);
if (old) consumePairing(old);
let code;
for (;;) {
code = String(crypto.randomInt(0, 1000000)).padStart(6, '0');
if (!pairings.has(code)) break;
}
pairings.set(code, {
profileId: auth.profileId,
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + PAIR_TTL_MS).toISOString(),
});
profilePairCode.set(auth.profileId, code);
// 日志不落配对码明文(与 PIN 同待遇:短时凭据不进日志)
console.log(`[server] 配对码签发: pid=${auth.profileId}(码 ****,10 分钟单次有效)`);
return sendJson(res, 200, { ok: true, code, expiresAt: new Date(Date.now() + PAIR_TTL_MS).toISOString() });
}
// ---- 配对码:新设备转入(可带当前匿名档案 token,服务器端按桶 LWW 合并进老档案) ----
if (route === 'POST /api/pair/join') {
const body = await parseBody(req);
const { code } = body;
if (typeof code !== 'string' || !PAIR_CODE_RE.test(code)) {
return sendJson(res, 400, { error: '配对码需为 6 位数字' });
}
sweepPairings();
if (pairJoinBlocked(req)) {
return sendJson(res, 429, { error: `配对码尝试错误次数过多,请 ${PAIR_BLOCK_MS / 60000} 分钟后再试(老设备可重新生成新码)` });
}
const entry = pairings.get(code);
if (!entry || !profileIndex.profiles[entry.profileId] || Date.parse(entry.expiresAt) < Date.now()) {
consumePairing(code); // 若是过期残留则顺带清掉
registerPairJoinFail(req);
return sendJson(res, 404, { error: '配对码不存在或已失效(10 分钟单次有效),请在老设备重新生成' });
}
const targetPid = entry.profileId;
const target = profileIndex.profiles[targetPid];
// joiner 身份可选:Bearer / body.token;无效或缺失一律视作无档案新设备(直发新 token)
const auth = authProfile(req)
|| (typeof body.token === 'string' && TOKEN_RE.test(body.token) ? authByToken(body.token.toLowerCase()) : null);
// joiner 已是该档案(同设备重复转入):幂等返回,消费码
if (auth && auth.profileId === targetPid) {
consumePairing(code);
return sendJson(res, 200, {
ok: true, merged: false, mergedGames: 0, profileId: targetPid, name: target.name,
token: auth.token, claimed: !!target.pinHash,
});
}
// 已设 PIN 的档案不参与合并(与 claim-merge 同防线):先建匿名档案再转入
if (auth && auth.profile.pinHash) {
return sendJson(res, 409, { error: '当前档案已设 PIN;请先用「+ 新玩家」建匿名档案再转入' });
}
consumePairing(code); // 同步作废 = 单次使用的原子保证(后续 await 期间并发 join 即失效)
let token;
let mergedGames = 0;
if (auth) {
const srcPid = auth.profileId;
mergedGames = await mergeGamesInto(targetPid, srcPid);
tokens[auth.token].profileId = targetPid; // token 改绑老档案
await persistTokens();
await retireProfile(srcPid);
console.log(`[server] pair 合并: ${srcPid} → ${targetPid}(${mergedGames} 桶更新,码 **** 已作废)`);
} else {
token = issueToken(targetPid);
console.log(`[server] pair 无档案转入: 新设备 → ${targetPid}(码 **** 已作废)`);
}
return sendJson(res, 200, {
ok: true, merged: !!auth, mergedGames, profileId: targetPid, name: target.name,
token: token || auth.token, claimed: !!target.pinHash,
});
}
// ---- 旧客户端兼容:登录(v1 用户名+PIN,映射到档案) ----
if (route === 'POST /api/login') {
const { username, pin } = await parseBody(req);
@@ -469,7 +646,9 @@ async function handleApi(req, res, url) {
await persistProfileIndex();
}
const token = issueToken(pid);
return sendJson(res, 200, { ok: true, token, username: profile.name, profileId: pid, name: profile.name });
return sendJson(res, 200, {
ok: true, token, username: profile.name, profileId: pid, name: profile.name, claimed: true,
});
}
// ---- 旧客户端兼容:注册(直接建已认领档案;新客户端请用 /api/device + /api/claim) ----
+251 -2
View File
@@ -11,6 +11,9 @@
* profiles/<pid>.json(PIN 明文即刻 scrypt 哈希、旧 token 继续有效)
* G. per-IP 匿名建档频控(429)与 /server/ 静态目录封禁(数据不外泄)
* H. 大厅 localStorage 分桶逻辑单测(web/profiles.js 纯函数,Map 版 storage)
* I. 配对码跨设备(P1):start(6 位/10min/同档案 1 码/频控)→ join 按桶 LWW 合并
* + token 改绑 → 单次使用作废 + join 5 连错封锁 + 已设 PIN joiner 拒绝 + 日志不落码
* J. token 180 天滑动过期:到期 401(大厅「档案已过期」引导的服务器侧路径)
*
* 运行:tools/node/bin/node web/server/test/api-smoke.mjs(或任意 node ≥18)
* 全部通过退出码 0;任一失败抛异常退出非 0。测试数据全部写进临时目录。
@@ -26,8 +29,8 @@ import { fileURLToPath } from 'node:url';
import {
BUCKETS, SETTINGS_KEY,
applyAuth, decideBucket, getCurrentProfile, migrateLegacyAuth,
nextLocalProfileName, readRegistry, restoreSnapshot, setCurrentProfile,
applyAuth, cnNum, decideBucket, digitsToCn, getCurrentProfile, migrateLegacyAuth,
nextLocalProfileName, readRegistry, removeProfile, restoreSnapshot, setCurrentProfile,
snapshotKeyOf, stashSnapshot, syncKeyOf, writeRegistry,
} from '../../profiles.js';
@@ -165,6 +168,47 @@ test('H4. 受管桶清单覆盖 8 个可同步游戏 + _settings', () => {
for (const b of BUCKETS) assert.ok(b.key.startsWith('soundgame.'), '桶 key 应保持游戏原始 key 名(游戏零改动)');
});
test('H5. 配对码逐位中文读法(0 读「零」非字母 O;speechSynthesis 前置纯函数)', () => {
assert.equal(digitsToCn('048920'), '零 四 八 九 二 零');
assert.equal(digitsToCn('000000'), '零 零 零 零 零 零');
assert.equal(digitsToCn('123456'), '一 二 三 四 五 六');
assert.equal(digitsToCn(''), '');
assert.equal(digitsToCn('9'), '九');
// 自动名词表与 server.js cnNum 同源(玩家一~九可听)
assert.equal(cnNum(1), '一');
assert.equal(cnNum(10), '十');
assert.equal(cnNum(21), '二十一');
});
test('H6. 从本机移除:清快照/同步表/注册表项;当前档案连带 CURRENT/AUTH,非当前不动 AUTH', () => {
const storage = mockStorage();
writeRegistry(storage, [
{ id: 'p_aaa', name: '玩家一', token: 't1' },
{ id: 'p_bbb', name: '玩家二', token: 't2' },
]);
setCurrentProfile(storage, 'p_bbb');
storage.setItem(snapshotKeyOf('p_aaa'), JSON.stringify({ 'soundgame.progress.rpg': '{}' }));
storage.setItem(syncKeyOf('p_aaa'), JSON.stringify({ rpg: { updatedAt: 'x', rev: 1 } }));
applyAuth(storage, { id: 'p_bbb', name: '玩家二', token: 't2' });
// 移除非当前档案:不清 CURRENT/AUTH
let list = removeProfile(storage, 'p_aaa');
assert.equal(list.length, 1);
assert.equal(storage.getItem(snapshotKeyOf('p_aaa')), null);
assert.equal(storage.getItem(syncKeyOf('p_aaa')), null);
assert.equal(storage.getItem('soundgame.profile.current'), 'p_bbb');
assert.ok(storage.getItem('soundgame.auth'), '非当前档案移除不应动 AUTH');
// 移除当前档案:连带清 CURRENT/AUTH(调用方负责新建/切换)
storage.setItem(snapshotKeyOf('p_bbb'), '{}');
list = removeProfile(storage, 'p_bbb');
assert.deepEqual(list, []);
assert.equal(storage.getItem('soundgame.profile.current'), null);
assert.equal(storage.getItem('soundgame.auth'), null);
assert.equal(storage.getItem(snapshotKeyOf('p_bbb')), null);
assert.equal(getCurrentProfile(storage), null);
});
function mockStorage() {
const m = new Map();
return {
@@ -423,3 +467,208 @@ test('G2. per-IP 匿名建档频控', async () => {
await srv.stop();
}
});
// ---------------- I. 配对码跨设备(P1) ----------------
test('I1. 配对码全流程:start(6 位/10min/同档案重生成顶替)→ join 按桶 LWW 合并 → 单次作废', async () => {
const srv = await startServer({});
try {
// 老设备:建档 + rpg 进度
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
const oldDev = { profileId: r.json.profileId, token: r.json.token, name: r.json.name };
r = await call(srv.base, 'PUT', '/api/progress', {
token: oldDev.token,
body: { game: 'rpg', data: { schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章·Lv7', lv: 7 } },
});
assert.equal(r.status, 200);
// start:6 位数字、10 分钟有效期、需鉴权
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
assert.equal(r.status, 200);
assert.ok(r.json.ok);
assert.match(r.json.code, /^\d{6}$/);
const firstCode = r.json.code;
const ttl = Date.parse(r.json.expiresAt) - Date.now();
assert.ok(ttl > 9 * 60 * 1000 && ttl <= 10 * 60 * 1000, `expiresAt 应为 ~10 分钟(实际 ${ttl}ms)`);
r = await call(srv.base, 'POST', '/api/pair/start');
assert.equal(r.status, 401, 'pair/start 必须鉴权');
// 同档案重新生成:旧码立即作废(同档案同时仅 1 个有效码)
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
const code2 = r.json.code;
let code = code2;
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const stranger = { token: r.json.token };
// 被顶替的旧码 → 404(计 1 次错误尝试,未到封锁阈值不影响后续)
r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code: firstCode } });
assert.equal(r.status, 404, '被重新生成顶替的旧码必须立即作废');
// 非 6 位数字 → 400(格式校验在先,不计入连错计数)
r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code: '000001-not-real' } });
assert.equal(r.status, 400, '非 6 位数字 → 400');
// 新设备:匿名档案 + dungeon 进度 + 更新的 rpg 进度
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const newDev = { profileId: r.json.profileId, token: r.json.token };
r = await call(srv.base, 'PUT', '/api/progress', {
token: newDev.token,
body: { game: 'dungeon', data: { schema: 1, summary: '第 3 层', floor: 3 } },
});
assert.equal(r.status, 200);
r = await call(srv.base, 'PUT', '/api/progress', {
token: newDev.token,
body: { game: 'rpg', data: { schema: 1, updatedAt: '2026-09-09T20:00:00Z', summary: '第三章·Lv12', lv: 12 } },
});
assert.equal(r.status, 200);
// join:新设备档案按桶 LWW 并入老档案,token 改绑(与 claim-merge 同语义)
r = await call(srv.base, 'POST', '/api/pair/join', { token: newDev.token, body: { code } });
assert.equal(r.status, 200);
assert.equal(r.json.ok, true);
assert.equal(r.json.merged, true);
assert.equal(r.json.profileId, oldDev.profileId, '新设备 token 应改绑到码对应的老档案');
assert.equal(r.json.name, oldDev.name);
assert.equal(r.json.token, newDev.token, 'join 复用新设备 token(改绑,不发新)');
assert.ok(r.json.mergedGames >= 2, `至少 rpg+dungeon 两桶更新(实际 ${r.json.mergedGames})`);
// 合并结果:LWW 裁决(rpg 取新设备的第 12 级;dungeon 是新设备独有桶)
r = await call(srv.base, 'GET', '/api/progress', { token: newDev.token });
assert.equal(r.json.games.rpg.data.lv, 12, 'LWW:新设备 rpg 更新,应胜出');
assert.equal(r.json.games.dungeon.data.floor, 3);
// 老设备 token 不受影响,且看到同一份合并后的档案
r = await call(srv.base, 'GET', '/api/progress?game=dungeon', { token: oldDev.token });
assert.equal(r.json.data.floor, 3);
// 单次使用:同一码再用 → 失效(404)
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const third = { token: r.json.token };
r = await call(srv.base, 'POST', '/api/pair/join', { token: third.token, body: { code } });
assert.equal(r.status, 404, 'join 成功后旧码必须立即失效');
// 无 token 新设备(全新浏览器)join:直接为老档案签发新 token
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
code = r.json.code;
r = await call(srv.base, 'POST', '/api/pair/join', { body: { code } });
assert.equal(r.status, 200);
assert.equal(r.json.merged, false);
assert.equal(r.json.profileId, oldDev.profileId);
assert.match(r.json.token, /^[0-9a-f]{48}$/, '无档案设备应拿到老档案的新 token');
r = await call(srv.base, 'GET', '/api/progress?game=rpg', { token: r.json.token });
assert.equal(r.json.data.lv, 12, '无档案 join 也能拉到合并后的进度');
// joiner 已设 PIN(已认领档案)→ 409,防误合并
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const claimed = { token: r.json.token };
r = await call(srv.base, 'POST', '/api/claim', { token: claimed.token, body: { pin: '1111' } });
assert.equal(r.status, 200);
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
code = r.json.code;
r = await call(srv.base, 'POST', '/api/pair/join', { token: claimed.token, body: { code } });
assert.equal(r.status, 409, '已设 PIN 的档案不允许作为 join 源被合并');
// joiner 已是该档案(同设备重复转入)→ 幂等成功,消费码
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
code = r.json.code;
r = await call(srv.base, 'POST', '/api/pair/join', { token: oldDev.token, body: { code } });
assert.equal(r.status, 200);
assert.equal(r.json.merged, false);
assert.equal(r.json.token, oldDev.token);
r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code } });
assert.equal(r.status, 404, '幂等转入同样消费码(单次)');
// 日志不落配对码明文(与 PIN 同待遇):本会话所有生成过的码逐一比对
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
const logged = srv.logs.join('');
for (const c of [code, code2, r.json.code]) {
assert.ok(!logged.includes(c), `服务器日志不得包含配对码明文 ${c}`);
}
} finally {
await srv.stop();
}
});
test('I2. 配对码防护:join 5 连错封锁 10 分钟、start per-IP 频控、过期码失败计数', async () => {
const srv = await startServer({ env: { SG_PAIR_LIMIT: '3' } });
try {
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
const dev = { token: r.json.token };
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const guesser = { token: r.json.token };
// 5 次错误码 → 全部 404,第 6 次起 429(封锁)
for (let i = 1; i <= 5; i++) {
r = await call(srv.base, 'POST', '/api/pair/join', { token: guesser.token, body: { code: '00000' + i } });
assert.equal(r.status, 404, `第 ${i} 次错误码应 404`);
}
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const legit = { token: r.json.token };
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
const code = r.json.code;
r = await call(srv.base, 'POST', '/api/pair/join', { token: legit.token, body: { code } });
assert.equal(r.status, 429, '连错 5 次后同 IP 一切 join 被封锁(含正确码)');
assert.ok(r.json.error.includes('10 分钟'), '封锁提示应引导等待与重新生成');
// start per-IP 频控:SG_PAIR_LIMIT=3 → 第 4 次 429
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
assert.equal(r.status, 200); // 2
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
assert.equal(r.status, 200); // 3
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
assert.equal(r.status, 429, 'pair/start 应受 per-IP 频控');
} finally {
await srv.stop();
}
});
// ---------------- J. token 180 天滑动过期(大厅「档案已过期」引导的服务器侧路径) ----------------
test('J1. 过期 token → 401;pair/join 对过期 token 视作无档案设备(引导可自愈)', async () => {
const dataDir = tmpDataDir();
// 手工落一份「token 已过期」的数据:1 个档案 + 1 条 179 天前过期的 token
const expiredToken = 'ab'.repeat(24);
fs.mkdirSync(path.join(dataDir, 'profiles'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'profiles.json'), JSON.stringify({
seq: 1,
profiles: { p_dead: { name: '玩家一', pinHash: null, createdAt: '2025-01-01T00:00:00Z' } },
}, null, 2));
fs.writeFileSync(path.join(dataDir, 'tokens.json'), JSON.stringify({
[expiredToken]: {
profileId: 'p_dead', createdAt: '2025-01-01T00:00:00Z',
lastSeen: '2025-01-01T00:00:00Z', expiresAt: '2025-06-01T00:00:00Z', // 早已过期
},
}, null, 2));
const srv = await startServer({ dataDir });
try {
// 全部鉴权端点对过期 token 一律 401(大厅据此弹「档案已过期,重新登录或新建」)
let r = await call(srv.base, 'GET', '/api/profile', { token: expiredToken });
assert.equal(r.status, 401);
assert.ok(r.json.error.includes('未授权'), '错误文案供大厅 isAuthExpired 判定');
r = await call(srv.base, 'GET', '/api/progress', { token: expiredToken });
assert.equal(r.status, 401);
r = await call(srv.base, 'PUT', '/api/progress', { token: expiredToken, body: { game: 'rpg', data: {} } });
assert.equal(r.status, 401);
r = await call(srv.base, 'POST', '/api/pair/start', { token: expiredToken });
assert.equal(r.status, 401);
// 过期 token 已被惰性清扫出 tokens.json
const tokensDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'tokens.json'), 'utf8'));
assert.ok(!tokensDisk[expiredToken], '过期 token 应被惰性清扫');
// 恢复路径 A(大厅「新玩家」):匿名建档照常可用
r = await call(srv.base, 'POST', '/api/device', { body: {} });
const helper = { token: r.json.token, profileId: r.json.profileId };
assert.notEqual(helper.profileId, 'p_dead');
r = await call(srv.base, 'GET', '/api/profile', { token: helper.token });
assert.equal(r.status, 200);
// 恢复路径 B(配对转入):pair/join 带过期 token 不 401,视作无档案设备直发新 token
// (服务器端对 join 的宽容 = 大厅引导文案「从其他设备转入」可以无感自愈)
r = await call(srv.base, 'POST', '/api/pair/start', { token: helper.token });
const code = r.json.code;
r = await call(srv.base, 'POST', '/api/pair/join', { token: expiredToken, body: { code } });
assert.equal(r.status, 200, 'join 对过期 token 应按无档案处理而非 401');
assert.match(r.json.token, /^[0-9a-f]{48}$/);
assert.equal(r.json.profileId, helper.profileId);
} finally {
await srv.stop();
}
});