diff --git a/.gitignore b/.gitignore index f09e5f36..3a8af596 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,13 @@ __pycache__/ # (the server answers /packs/index.json itself; tools/package.mjs writes this file into the release zips) ----------- packs/index.json +# --- 森空岛 account binding (docs/skland.md): the local credential database — a secret, never committed ------------ +# SP_SKLAND_DB default (server/skland/store.js) plus the SQLite WAL side files; skland-data/ is the Docker volume path +data/skland.db +data/skland.db-wal +data/skland.db-shm +skland-data/ + # --- tests & logs ---------------------------------------------------------------------------------- test/e2e/out/ logs/ diff --git a/Dockerfile b/Dockerfile index 33b45355..28d4960f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,18 @@ # (public/fonts, data/assets.json and data/local-assets.json are copied from the build context when present) # Without any art the game still runs with placeholder visuals. # +# Data: the opt-in Skland account binding (SP_SKLAND=1, docs/skland.md) is the only runtime state: one SQLite file of +# account credentials (SP_SKLAND_DB). It must survive a container restart, so it belongs on a volume — this image +# creates /app/skland-data and hands it to `node` below, and docker-compose.yml mounts the named volume `skland-data` +# there with SP_SKLAND_DB=/app/skland-data/skland.db. There is deliberately no `VOLUME` instruction: an anonymous +# volume would make `docker run` silently accumulate credentials a host cannot find, so the mount is the caller's +# explicit choice (`-v skland-data:/app/skland-data`). The default (data/skland.db, inside the container) is only for +# a throwaway run. The database is a secret: it holds account credentials, it sits outside every static mount, and it +# must never be committed, served over HTTP or copied around unencrypted. +# # Run: docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol -# Env: PORT (3000), HOST (0.0.0.0), SP_COMBAT (client|server), SP_VERIFY (off|sample|all), TRUST_PROXY (auto|1|0), DEBUG +# Env: PORT (3000), HOST (0.0.0.0), SP_COMBAT (client|server), SP_VERIFY (off|sample|all), TRUST_PROXY (auto|1|0), DEBUG, +# SP_SKLAND (off; 1 turns the Skland account binding on), SP_SKLAND_DB (data/skland.db) ARG NODE_IMAGE=node:22-alpine @@ -53,6 +63,9 @@ COPY --from=build /app/data ./data COPY --from=build /app/public ./public # research tables: read by server/sim/nodeData.js as a fallback COPY --from=build /app/docs/research ./docs/research +# the Skland credential database's directory (SP_SKLAND_DB): created before USER node and owned by it, so a fresh named +# volume inherits a writable mount point and the unprivileged user can create the .db and its -wal / -shm side files +RUN mkdir -p /app/skland-data && chown node:node /app/skland-data USER node EXPOSE 3000 diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 900b5faa..e73128a1 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -17,6 +17,7 @@ them (with `node_modules/`, which keeps each package's own licence file). | [Preact](https://github.com/preactjs/preact) | 10.29.8 | MIT | UI — `public/vendor/preact.module.js`, `hooks.module.js` | no (npm) | yes | | [htm](https://github.com/developit/htm) | 3.1.1 | Apache-2.0 | UI templates — `public/vendor/htm.module.js` | no (npm) | yes | | [three.js](https://github.com/mrdoob/three.js) | 0.186.1 | MIT | official 3D board — `public/vendor/three.core.js`, `three.module.js` | no (npm) | yes | +| [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) | 2.0.4 | MIT | the 森空岛 binding panel renders the scan-login QR client-side — `public/vendor/qrcode.js` (a UMD build loaded as a classic script, so the global `qrcode`) | no (npm) | yes | | [ws](https://github.com/websockets/ws) | 8.22.0 | MIT | WebSocket server (`server/`) | no (npm) | yes (`node_modules/`) | | [Node.js](https://nodejs.org/) | v22.23.3 (pinned in `scripts/make-windows-bundle.mjs`) | MIT | the portable `node\node.exe` (**Windows portable package only** — the integration bundle in [Releases](../../releases/latest) ships no `node.exe`) | no (downloaded from nodejs.org at package time, sha256 verified) | **Windows portable package only** (`node\node.exe`, with `node\LICENSE-node.txt`) | | [puppeteer-core](https://github.com/puppeteer/puppeteer) | 25.12.0 | Apache-2.0 | optional browser tests (dev dependency) | no (npm) | no | @@ -177,6 +178,41 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +### qrcode-generator — MIT + +```text +QR Code Generator for JavaScript + +Copyright (c) 2009 Kazuhiko Arase + +URL: http://www.d-project.com/ + +Licensed under the MIT license: + http://www.opensource.org/licenses/mit-license.php + +The word 'QR Code' is registered trademark of +DENSO WAVE INCORPORATED + http://www.denso-wave.com/qrcode/faqpatent-e.html + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` + ### ws — MIT ```text diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..ddd56ce0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +# 卫戍协议:盟约 · one command: `docker compose up -d` (docs/DEPLOY.md「Docker」). +# The service builds the repository's own Dockerfile and tags the image; no other service is needed. +services: + stronghold: + build: + context: . + # A) download the art while building (~550 MB, needs internet): uncomment + # args: { FETCH_ASSETS: "1" } + image: stronghold-protocol + restart: unless-stopped + ports: + - "3000:3000" + environment: + # Skland (森空岛) account binding: SP_SKLAND=1 turns the module on (docs/skland.md) and SP_SKLAND_DB points its + # SQLite credential store at the named volume below. To run without the module, comment out (or delete) both + # lines — the server then keeps no account credentials at all and the volume is never written. + SP_SKLAND: "1" + SP_SKLAND_DB: /app/skland-data/skland.db + volumes: + # The credential database must survive `docker compose down`, `docker compose up --build` and image rebuilds, so + # it lives in a named volume instead of the container's writable layer. A named volume behaves the same on Windows, + # macOS and Linux and needs no host path or permission setup; `docker compose down -v` is what deletes it. + # The database is a secret: never publish or serve it over HTTP (it is outside every static mount), and back it + # up carefully (docs/DEPLOY.md「Docker」). + - skland-data:/app/skland-data + # B) the host's art instead of the downloaded copy, read-only (prepare it with `node tools/setup.mjs`): uncomment + # - ./public/assets:/app/public/assets:ro + +# One named volume, one runtime database. `docker volume inspect` shows where the host keeps it; it is private to the +# container user, so a bind mount (`./skland-data:/app/skland-data`) is only needed to read the file from the host. +volumes: + skland-data: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a491d1a4..841e873b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,7 +25,9 @@ effects), [DATA.md](DATA.md) (generated data), [ASSETS.md](ASSETS.md) (art and a - **The server** is one Node process: plain `node:http` plus `ws`, no framework (`server/index.js` wires `server/http/`). It serves the static client, the generated data and the sim's source files, and runs every room and - match in memory — rounds, shop, economy, bots, validation. Nothing is written to disk. + match in memory — rounds, shop, economy, bots, validation. Optional account binding (`SP_SKLAND=1`) persists + credentials and JWT login records in SQLite ([skland.md](skland.md)); a selected role can resume its retained seat + across devices through authenticated account recovery. Match state remains in memory, not in SQLite. - **The browser** loads native ES modules with no bundler and no build step; the libraries are vendored into `public/vendor/` by `tools/vendor.mjs` on `npm install`. - **The battle simulation** (`server/sim/`) is pure ESM without any Node API, served read-only at `/sim/`. Both sides @@ -79,6 +81,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi | `server/index.js` | the process entry (`npm start`); `startServer()` wires `server/http/` | | `server/http/` | `config.js` (environment), `websocket.js` (sessions, `/ws`, permessage-deflate), `static.js` (the mounts, the `~` asset routes), `media.js`, `files.js` (MIME, brotli / gzip, ETag, ranges), `buildTag.js`, `routes.js` (`/healthz`), `traffic.js` (bytes sent by category and on the WebSocket, `/healthz?traffic=1`), `assetIndex.js` (the content hashes of `public/assets/` and `public/fonts/`, cached in `.cache/asset-hashes.json`), `assetViews.js` (the served `/data/assets.json` and `/data/local-assets.json`: every art URL with its hash segment; ASSETS.md), `common.js`, `boot.js` (a pending update package first, banner, shutdown) | | `server/net.js` | sessions and reconnect tokens, rate limits, message validation | +| `server/skland/` | opt-in provider binding and SQLite login records; `auth.js` verifies account ownership, `accountHttp.js` handles login/entry/recovery, `recovery.js` describes only the selected role's retained room or match | | `server/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` | | `server/data.js` | loads `data/*.json` once (frozen) | | `server/packs.js` | the content packs (PACKS.md): finds the language packs of `public/i18n/` and the pack folders of `packs/`, validates them, answers `/packs/index.json` and which pack files may be served; re-reads the folders when they change | @@ -102,6 +105,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi |---|---| | `public/index.html`, `public/js/main.js` | the page and its entry: boot, the router (title → lobby → room → game) | | `public/js/net.js`, `public/js/store.js`, `public/js/data.js` | the socket client, the observable store, the data loader (`/data/*.json`, with the English overlay) | +| `public/js/skland/` | the account dialog, role selection, entry gate and server-backed cross-device recovery picker; no local match-token list is used for account recovery | | `public/js/battle/` | `runner.js` (the local battle: loads `/sim/`, steps it, reports), `observe.js` (who may watch which field) | | `public/js/screens/` | `title.js`, `lobby.js`, `room.js`, `loadout.js` (干员调配), `cultivation.js` (its 潜能 / 练度 controls), `ownership.js` (干员持有), `diy.js` (自选编队), `briefing.js`, `bandDraft.js`, `game.js` with `public/js/screens/game/`, `result.js` | | `public/js/ui/` | the HUD components (`hud.js`, `shopBar.js`, `detailPanel.js`, `bondStrip.js`, `teamPanel.js` …); `public/js/ui/gameLogic/` the pure in-match logic, unit-tested in Node | diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 484de8b4..cd5b8dde 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -13,7 +13,7 @@ | 磁盘 | 素材约 550 MB(`public/assets`,含中文约 65 MB、日文约 85 MB 两套干员语音)+ 依赖约 125 MB(`node_modules`;整合包只带运行依赖,约 65 MB);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。完整包解压后约 790 MB。 | | 玩家设备 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑或手机平板(横屏)。老旧设备可在设置里调低画质或访问 `/?board=2d`。 | -服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。 +服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。唯一例外是打开可选的森空岛账号绑定(`SP_SKLAND=1`,第 3.2 节):它的凭据库需要持久化和备份。 ## 1. Windows 小主机:一步步 @@ -180,7 +180,7 @@ cloudflared tunnel --url http://localhost:3000 2. 路由器「虚拟服务器 / 端口转发」:外部端口 3000(或任意端口)→ 内部 `主机IP:3000`,TCP。 3. 朋友访问 `http://<你的公网 IP>:外部端口`。 -注意:游戏没有账号系统,知道地址的人都能进来。服务器对来自互联网的连接有按网络的数量限制(每个网络最多 64 个连接,房间 / 对局数量也有上限),但仍建议不玩时关掉转发,或优先用 Tailscale。 +本分支的标题入口要求绑定账号(第 3.2 节);启用模块后,服务器也会校验房间/对局操作对应的账号角色。服务器对来自互联网的连接有按网络的数量限制(每个网络最多 64 个连接,房间 / 对局数量也有上限),建议不玩时关掉转发,或优先用 Tailscale。公网账号登录使用 HTTPS(2.4)。 ### 2.4 反向代理与 HTTPS(有域名时) @@ -231,8 +231,20 @@ https / wss 说明:页面通过 https 打开时客户端自动连接 `wss:// ## 3. Docker +### 3.1 一条命令(docker compose) + +仓库根目录的 `docker-compose.yml` 已经把构建、端口、重启策略和数据卷写好,在项目根目录执行: + ```bash -# A) 构建时下载素材(需要联网,约 550 MB) +docker compose up -d # 构建镜像并后台启动(首次会构建;HTTP 服务在 http://<主机>:3000) +docker compose logs -f # 跟随日志;docker compose ps 看状态 +docker compose down # 停止并删除容器(数据卷 skland-data 保留) +``` + +不用 compose 时等价的原始命令(`FETCH_ASSETS=1` 在构建时下载素材,约 550 MB,需要联网;不下载则用占位图,或改用 B 挂载宿主机素材): + +```bash +# A) 构建时下载素材 docker build -t stronghold-protocol --build-arg FETCH_ASSETS=1 . docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol @@ -242,22 +254,50 @@ docker run -d --name stronghold -p 3000:3000 --restart unless-stopped \ -v "$PWD/public/assets:/app/public/assets:ro" stronghold-protocol ``` -镜像从源码(`git clone`)构建,Releases 的整合包不含 `Dockerfile`。镜像基于 `node:22-alpine`,多阶段构建,只含生产依赖;`public/vendor` 在构建时生成。`.dockerignore` 排除了 `public/assets`(不会把宿主机素材打进构建上下文);`public/fonts`、`data/assets.json` 和 `data/local-assets.json` 若存在会被复制进去。环境变量同 README(`-e SP_VERIFY=sample` 等)。健康检查:`GET /healthz`。 +镜像从源码(`git clone`)构建,Releases 的整合包不含 `Dockerfile`,也不含 `docker-compose.yml`。镜像基于 `node:22-alpine`,多阶段构建,只含生产依赖;`public/vendor` 在构建时生成。`.dockerignore` 排除了 `public/assets`(不会把宿主机素材打进构建上下文);`public/fonts`、`data/assets.json` 和 `data/local-assets.json` 若存在会被复制进去。环境变量同 README(`-e SP_VERIFY=sample` 等),另有第 3.2 节的森空岛两项。健康检查:`GET /healthz`。 -docker compose 示例: +### 3.2 森空岛账号绑定与数据卷 + +可选的**账号绑定**(`server/skland/`,见 [skland.md](skland.md))把账号凭据、JWT 签名密钥和可撤销的登录记录存进同一个 SQLite 文件;这是服务器需要跨重启保留的账号数据(房间和对局仍然只在内存里,第 0 节)。JWT 以 HttpOnly Cookie 保存 30 天,刷新、重新开页面或重启服务不用重新绑定;解绑会使旧 JWT 失效。不设置 `SP_SKLAND` 时模块不启用、不建库,`/skland/*` 统一返回 JSON 404;本分支的标题入口仍要求绑定,因此开服需要启用它。需要含 `node:sqlite` 的 Node 22.5 或更新版本。 + +| 环境变量 | 默认 | 说明 | +|---|---|---| +| `SP_SKLAND` | 空(关闭) | 设为 `1` 时打开账号绑定模块 | +| `SP_SKLAND_DB` | `data/skland.db` | SQLite 凭据库的路径(相对路径相对项目根目录);父目录不存在时自己创建 | +| `SP_AUTH_SECRET` | 自动生成并存入数据库 | 可选的 JWT 签名密钥,至少 32 字节;不要每次启动随机改变,轮换会使现有登录失效 | +| `SP_PUBLIC_ORIGIN` | 按请求来源判定 | 可选的固定访问地址,例如 `https://game.example.com`,用于同源校验和 HTTPS Cookie | +| `SP_SKLAND_DIAGNOSTIC_KEY` | 空(关闭) | 仅管理员诊断接口使用;调用需 `X-SP-Diagnostic-Key`,不要填进前端或发给玩家 | + +`docker-compose.yml` 里这两个变量都指向挂载的具名卷: ```yaml -services: - stronghold: - build: - context: . - args: { FETCH_ASSETS: "1" } - ports: ["3000:3000"] - restart: unless-stopped environment: - SP_VERIFY: "off" + SP_SKLAND: "1" + SP_SKLAND_DB: /app/skland-data/skland.db + volumes: + - skland-data:/app/skland-data ``` +- **为什么用卷**:容器以 `USER node` 运行,删除容器、`docker compose down` 或重建镜像都会丢掉镜像层里的文件;数据库放在具名卷 `skland-data` 上,重启、`docker compose down` 和 `docker compose up --build` 之后绑定关系都还在。`docker compose down -v`(或 `docker volume rm`)才会删除它。 +- **为什么用具名卷而不是绑定挂载**:Windows / macOS / Linux 上行为一致,不需要先建宿主机目录、也不用管容器内 `node`(uid 1000)对宿主机目录的权限。要在宿主机上直接读这个文件时,把挂载换成 `- ./skland-data:/app/skland-data`(目录已在 `.gitignore` 里)。 +- **关掉模块**:把 `SP_SKLAND` 和 `SP_SKLAND_DB` 两行删掉或注释掉即可(卷可以留着不用),服务器就不再保存任何账号凭据。手工 `docker run` 时对应地去掉 `-e SP_SKLAND=1 -e SP_SKLAND_DB=…` 两个参数。 +- **改用别的路径**:`SP_SKLAND_DB` 换成卷里的其它位置(例如 `/app/skland-data/skland-2.db`)。内置静态服务会拒绝下载当前配置的库及 WAL/SHM 文件,但仍建议放在所有静态目录之外,避免其它文件服务器或代理规则泄露。 + +公网使用 HTTPS;代理要保留 Host 并传递 `X-Forwarded-Proto`(2.4),也可显式设置 `SP_PUBLIC_ORIGIN`。 +HTTPS 下 Cookie 带 Secure;本地 `127.0.0.1` 开发使用 HTTP。游戏内用角色 UID + 区服标识账号,昵称可以重复。 +登录记录不保存密码,JWT 也不携带鹰角/森空岛凭据。 + +**随账号跨设备恢复**:新设备登录同一账号并选择同一区服角色后,可在标题页点击「恢复账号对局」,无需复制 +本机缓存。查询对局或自动恢复登录不会挤掉旧设备;点击「开始」或确认恢复后才接续原席位,旧设备断开且不会 +自动抢回连接。恢复仍受原有断线期限限制(独立模拟 24 小时、同盟对局 10 分钟,等候房间另受 60 秒宽限期限制), +**不包含服务器重启后的对局存档**。只会查询当前角色,昵称相同或其他区服不会串号。 + +> **这个数据库是凭据,按密钥对待。** `skland.db` 里是明文保存的 `access_token`、长期 `cred` 和签名密钥 `cred_token`,拿到文件等于拿到对应账号的接口权限(skland.md §9): +> - **限制权限**:只让运行服务器的账号可读,例如 `chmod 700 skland-data && chmod 600 skland-data/skland.db*`(WAL 模式下还会有 `-wal` / `-shm` 两个附属文件,同样处理)。在宿主机上运行而非容器里时默认路径是 `data/skland.db`,权限同理。 +> - **绝不通过 HTTP 暴露**:卷挂在 `/app/skland-data`,不在 `public/`、`data/`、`shared/` 任何静态挂载之下;反向代理也不要为它开规则。 +> - **不提交、不进镜像**:`.gitignore` 忽略了 `data/skland.db`、`-wal` / `-shm` 和 `skland-data/`;构建上下文只会把 `data/` 复制进镜像,所以容器部署要把库放在 `data/` 之外(compose 已经这么做)。 +> - **备份要当密码处理**:`docker compose stop` 后再从卷里拷贝(WAL 写入中直接拷可能拿到不一致的副本),备份文件加密保存、限权,不要放进网盘 / 仓库 / 聊天记录;不再使用时删除卷(`docker compose down -v`)。 + ## 4. macOS / Linux 常驻 - 临时开服:`scripts/start.sh`(或 `npm start`),保持终端窗口打开。macOS 首次会询问是否允许 node 接受传入连接,选「允许」。 diff --git a/docs/META.md b/docs/META.md index 0f37c3d0..e64201f5 100644 --- a/docs/META.md +++ b/docs/META.md @@ -175,6 +175,11 @@ otherwise every "之后 / 后续的每场作战" card — e.g. 教鞭's 法术 A `choice:` registry handler overrides the default application (§2.4). ### 1.3 Disconnects, AI takeover +* Account-required fork (§31.13, [skland.md](skland.md) §5): the selected UID + region can resume the same retained + seat on another device after explicit authenticated entry. Login/lookup alone does not replace a socket. The + transport rotates its token on successful attachment, while the match and its private state stay the same. + Account recovery does not extend the deadlines below or survive a server restart; an expired role's old seat goes + through the normal lobby onExpire/onLeave cleanup before a new session can reuse its stable playerId. * Local multi-window recovery (#431, DESIGN §28.27) sends optional `hello.noReplace` for a tentative shared token, and `hello.claimAt` for a previously welcomed token. Before binding a different socket, the server rejects a connected seat when no replacement was allowed or the request has a later stamp than the attached holder diff --git a/docs/design/client.md b/docs/design/client.md index fea08729..aa2cec45 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -73,6 +73,37 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer --- +### Required account binding at the callsign field (fork integration) + +The title hides 博士代号 until binding, then displays the role nickname as plain text without an input +(`public/js/skland/entry.js`; +`SP_SKLAND=1`, [skland.md](../skland.md)). The primary button shows 绑定账号 until a named role is fetched, then 开始. +Multiple roles start with the default role and can be switched. Unbinding or an empty role list clears the callsign +and restores 绑定账号. The nickname retains its person icon. The scan tab fetches a QR automatically and refreshes it +every 10 s; the first awaiting-confirmation response starts a 60 s window. Refresh keeps the current image visible +until its replacement is ready and updates the image source in place. Manual refresh remains available, with +serial 2 s polling and paused timers outside the scan tab/visible page. Saved callsigns/entered flags and account recovery cannot skip the binding lookup. Skland QR +login supports official/Bilibili channels; phone/password uses the Hypergryph account. Every game-entry HTTP request +uses the existing game session token and a 30-day HttpOnly JWT cookie, without entering the lobby automatically. +The cookie restores the role in a new tab or after a server restart; role choice persists on the server. The callsign +never becomes an identity key: UID plus server region determine a stable game player id, allowing duplicate names. +Account UI, styles and the entry policy stay in independent module files; title, boot and recovery have small hooks +to reduce upstream merge conflicts. An optional server account policy also checks hello and room/game requests +against the JWT-owned role; editing the client's UUID, region or name cannot select another player's binding. +Unbind revokes remembered login. Owner request, 2026-10-14; §31.12 and [skland.md](../skland.md). + +The title's 恢复账号对局 picker queries the selected role's live server-side seat, even on a device with no local match +history. It replaces the visible 本机 recovery buttons (including the obsolete lobby shortcut). Opening it or restoring +the login cookie does not disconnect an active device. 开始 or an explicit recovery confirmation asks for the role's +current token, reconnects with the JWT, and enters only after welcome confirms the expected playerId. The existing +socket is then replaced (4001, no auto-reconnect), and its old token cannot take the role back on reload. A duplicated +tab or stale saved entered flag stays at the title unless its own current role token really resumed. Any anonymous +welcome also clears an already-entered shell; the connection banner's 在此页面继续 uses checked account entry. +The picker shows +empty, loading and error states, a takeover notice and a refresh action; an expired/replaced selector cannot create a +new match. Room/match state is still memory-only with the existing disconnect windows; server restart ends it. +The UI and entry adapter remain in public/js/skland/ (§31.13). + ## 13. Local-client art (optional, host-side) `tools/local-extract/extract.py` (Python: UnityPy + lz4 + Pillow; decodes Arknights' custom **LZ4AK** bundle compression via `aklz4.py`) extracts the art the web download does not include (some of it, most official HUD sprites among it, is on the public mirror too — §22.5) from a locally installed client (CrossOver Windows build or PlayCover iOS build) into `public/assets/local/**` and writes `data/local-assets.json` (`{ groups: { '': { name: { path, w, h, kind } } } }`). Everything here is **optional**: clients must render correctly when the file or any entry is missing (fall back to the web assets / procedural art). The emotes and the guide pages below are on the public mirror too (§22.5): fetch-assets downloads them (data/assets.json `ui['emoticon//']`, `ui['guide/']`) and the client shows the local copy first, then that one (`data.js artUrls`); docs/DEPLOY.md §6 lists what still needs the client. diff --git a/docs/design/network.md b/docs/design/network.md index 98bff74b..6e7334f5 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -4,6 +4,26 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global). ## 8. Network protocol (shared/protocol.js is normative) +**Fork account policy (opt-in `SP_SKLAND=1`, owner request 2026-10-14).** The existing WebSocket messages are +unchanged. Hello may establish anonymous transport for account login, but room/game requests require the role +owned by a persistent, server-verified JWT cookie. The server ignores a client's claimed nickname/UUID for account +identity; it derives stable `playerId` from UID plus provider-reported server region (`channelName`). Nicknames +may repeat, and optional provider `gameId` metadata does not affect identity. The public id uses 128 SHA-256 digest +bits encoded as base64url after `a_` (24 characters), so normal field ids and per-player battle reports fit the +existing protocol limits. A reconnect token alone cannot resume +an account identity. A valid login plus the role's current reconnect token can resume its previous socket; each +game frame checks the current login record and selected role, so logout or a role switch invalidates its authority. +Cookie restoration and read-only recovery lookup never take over another device. An explicit authenticated +enter/resume POST returns the selected role's token; the existing hello then attaches it and resends room/match state. +Only after successful attachment is the account token rotated, so a stale device cannot silently retake the seat. +The client stages the entry token in memory until welcome; local multi-tab claims still protect normal reconnects. +Login lasts 30 days, survives process restart and is revoked on unbind. Room/match data remain in memory. +Recovery selectors are scoped to the selected role and the particular room/match; stale selectors return 409 without +creating a new game. The existing 10-minute / solo 24-hour disconnect windows are unchanged, and an expired seat is +removed through the lobby before its stable playerId can be reused. Account API ownership and private DB handling +are specified in [skland.md](../skland.md) §5/§9. The optional policy hooks leave module-off transport unchanged; +no simulation rule, WebSocket message or protocol-version change is involved. + Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound (a compressed frame counts its inflated size); rate limit 40 msgs/s per connection (excess dropped with `error RATE`). Frames from 1 KB up are compressed with permessage-deflate (RFC 7692; `server/http/websocket.js WS_DEFLATE`: no server context takeover, a 2 KB server window, zlib memLevel 7 — about 72 KB of zlib state per connection); smaller frames go out plain, and a client that offers no compression, or caps the server window below 2 KB, gets plain frames. A measured solo session (rounds 3–5, `/healthz?traffic=1`): 173 KB of server frames → 60 KB on the wire. ### 8.1 Session & lobby @@ -137,6 +157,8 @@ voting, refuses stale ids and cancels on disconnect/leave; DESIGN §28.21 / META Explicit local recovery (§28.20) reuses saved hello tokens after a fresh live-tab claim check. Display names and room codes are UI metadata only, never authentication. No new network message or nickname/IP matching is introduced. +The account-required fork replaces the visible local recovery entry with authenticated account recovery (§31.13); +the generic local helper and its module-off handshake behavior are retained. Optional `coins: [[id, balance, cap]]` is an integer skill-currency readout (§29.6), separate from ammo. It includes zero, is stepped between snapshots and clears when omitted. Result-validation names for board operators are resolved from the server's DIY/stand-in selections, never a supplied display name. diff --git a/docs/history/0.2.6.md b/docs/history/0.2.6.md index f062743d..74a868fa 100644 --- a/docs/history/0.2.6.md +++ b/docs/history/0.2.6.md @@ -341,3 +341,68 @@ every vertex on a slab tile at its top or its foot; 战场#03 and #05(上半) ha `test/render/board3d-scene.test.js` (an official scene on 战场#03 keeps the slabs in the board material and nothing else of the procedural board; dropping the scene restores the board; act2 m02 adds no slab mesh). Screenshots of the five affected stages before / after / metal board were compared by hand. Golden: unchanged (client only). + +### 31.12 Account binding before entry (fork integration, 2026-10-14) + +Owner request: integrate the working account module at 博士代号, automatically use the bound role's nickname, retain +Skland QR for official/Bilibili channels, and keep later upstream merges small. The callsign label and nickname are +hidden until binding, then shown as plain text without an input; 绑定账号 +replaces 开始 until a named role is obtained, and returns after unbinding or an empty role list. Boot and local-match +recovery recheck the binding before accepting a cached entry. The dialog, styles, entry policy and session resolver +live in the module's own directories, with small title/boot/recovery hooks. Game-entry requests use the +existing reconnect token and a reserved namespace; the independent diagnostic routes require an administrator key +and cannot read or mutate these rows. No new WebSocket messages or simulation rules. Multiple roles select the default, retain a +still-valid choice on refresh/reopen, and immediately update the callsign when switched. No manual callsign entry. +The official account-page icons are referenced remotely; authorization redirects remain provider-operated. +The nickname retains its person icon. Opening the scan tab automatically fetches a QR: replacement is every 10 s, +switching to 60 s from the first status 101 (waiting for phone confirmation), with manual refresh retained. Refresh +keeps the old QR visible until the new image is encoded, then replaces its source in place without a blank frame. Requests +are serial and check confirmation before replacement; switching tabs or hiding the page pauses timers, and closing +the dialog or binding stops them. The provider's 101 is exposed as `awaitingConfirmation` without changing `waiting`. + +Persistent login uses a 30-day HS256 JWT in an HttpOnly, SameSite=Strict cookie (Secure over HTTPS), with signing +key and revocable login/role records in the same SQLite database. New tabs and restarted servers restore the +selected role without another provider login. The JWT contains no provider credentials. Account APIs derive +ownership on the server, reject cross-site requests and foreign role selection, and default-close diagnostic access. +The optional WebSocket account policy resolves hello and every room/game request to the owned role; a copied game +reconnect token alone cannot claim it. UID plus server region determines stable game identity, allowing duplicate +nicknames; optional `gameId` and name changes do not change that identity. Unbind clears credentials and revokes +old JWTs/open-socket authority; late provider replies cannot restore a cancelled binding. Static/media/pack paths +deny the configured database and its WAL/SHM files, including aliases and Windows casing. These changes follow the +owner's explicit persistence/access-control request; rooms/matches remain memory-only. + +Reference: [skland.md](../skland.md). Tests cover token rejection/player isolation, nickname selection, authenticated +requests and the real title/modal in a browser with a stub account provider (QR, SMS and password/CAPTCHA continuation, +landscape-phone layout, hidden/plain-text callsign with person icon, empty roles, unbinding, fresh-tab JWT restoration +and cached-entry checks). Server tests cover forged JWT/claims, foreign roles, logout/replay, restart/expiry, +protected database files, cancelled provider replies and same-name/different-UID/region identities. +Deterministic timer tests cover automatic QR generation, exact 10/60 s boundaries, pause/resume, late responses, +manual refresh versus confirmation and retry after failure. Golden: unchanged +(no simulation changes). + +### 31.13 Account-scoped cross-device recovery (fork integration, 2026-10-14) + +Owner request: continue implementing recovery with the account across devices. The title now offers 恢复账号对局 +from authenticated server state, replacing the visible local-token recovery shortcuts. A device with no local save +can sign in independently, select the same UID + provider region, inspect its retained seat and continue it. Login, +cookie restoration and lookup alone do not disconnect another device; Start or recovery confirmation explicitly +obtains the role's reconnect token. Only a successful welcome enters the UI, rotates the token and replaces the old +socket. Old-device reloads and duplicated tabs cannot silently take the role back; the connection banner can still +explicitly continue through checked account entry, and anonymous welcomes return to the title. The UI handles empty, loading, +stale/error and refresh states and warns about taking over a live connection. + +The server checks both credentials and the selected role, never a supplied player/name/UID/region. Public account +ids encode 128 digest bits as base64url after a_ (24 characters): the previous full hexadecimal digest exceeded +player-id and normal-field limits, rejecting watch/kick/battle-result messages. No wire limit needs to change. Recovery selectors +belong to a particular room/match; stale or foreign selectors fail without minting a fresh game. Looking up a seat +does not extend its lifetime. The normal lobby cleanup runs before an expired stable role id is reused, preventing +an orphaned old match. The same Match/PlayerState is resumed, including funds, shop and lineup; no simulation rule, +protocol message or generated data changes. Solo 24-hour/co-op 10-minute disconnect windows and waiting-room grace +are unchanged. Matches remain memory-only: this is not save recovery after a server restart, nor synchronization of +local preferences/statistics. These are owner-requested account policies, not deviations from official gameplay. + +Normative updates: design/network.md §8 and its recovery note, design/client.md required-account entry, META.md §1.3; +references: skland.md §5 and ARCHITECTURE.md / DEPLOY.md. Tests: test/skland/recovery.test.js exercises actual Match +objects through independent HTTP/WS clients; test/skland/recovery.browser.test.js runs two isolated browser contexts +with a stub provider, plus old-device and duplicated-tab checks and desktop/phone layout. Existing account, local +identity, i18n and docs checks are rerun. Golden moves: none (no match/sim rules changed). diff --git a/docs/skland.md b/docs/skland.md new file mode 100644 index 00000000..148633c3 --- /dev/null +++ b/docs/skland.md @@ -0,0 +1,371 @@ +# skland.md — the opt-in Skland account binding + +`server/skland/` reads a player's Arknights account bindings and associates the roles with their game session. +The title hides the 博士代号 label and nickname until binding, then displays them as plain text without an input. +Logging in fetches the callsign from the default role's `nickName`; choosing another role updates it immediately. +The primary button shows 绑定账号 until a named role is available, then 开始. Unbinding +or an empty role list clears the callsign and restores 绑定账号. Boot and account-match recovery recheck the current +server binding, so a cached manual callsign/entered flag cannot skip it. This fork requires binding for browser entry; +an operator must enable the module to let players enter. When enabled, the server also requires the authenticated +role for room/game requests. Login persists for 30 days through an HttpOnly JWT cookie, including across server restarts. +Password login uses the official Hypergryph account binding service directly; the existing QR/SMS paths use the +signed Skland API. The historical module name, URL paths and enable switch are retained for compatibility. +It is **off by default** and stays off unless an operator turns it on +(§2); an unconfigured server keeps no account credentials at all. It uses the public website's account endpoints and +the community-documented Skland endpoints (§10); enabling it and storing credentials is the operator's decision, not a +feature every server gets. The module reaches the network only when the server does; the tests inject a stub `fetchImpl`. + +## 1. What it is, and what it is not + +It is a per-player credential store plus a login client and a small JSON HTTP surface. It does: run the three account +login paths (扫码 / 验证码 / 密码), fetch the account's bound roles, refresh an expired credential once, and answer the +binding state to the game. It does **not** solve 人机验证 (§4), spend any credential, or post anything on the player's +behalf. The game entry uses a reconnect token for transport and a server-verified JWT for account ownership (§5). +The independent diagnostic routes are closed by default; an administrator key enables their caller-supplied ids, +excluding the game's reserved pending/account namespaces. + +## 2. Turning it on + +| Switch | Effect | +|---|---| +| `SP_SKLAND=1` | enables the module (exactly `1`; any other value leaves it off) | +| `SP_SKLAND_DB=` | the SQLite file (default `/data/skland.db`) | +| `SP_AUTH_SECRET=` | optional HS256 signing secret, at least 32 bytes; otherwise generated once and persisted in the database | +| `SP_PUBLIC_ORIGIN=https://game.example.com` | optional canonical browser origin, useful behind a reverse proxy | +| `SP_SKLAND_DIAGNOSTIC_KEY=` | enables administrator diagnostic routes; callers must supply `X-SP-Diagnostic-Key` | +| `startServer({ skland: true })` | same as `SP_SKLAND=1` | +| `startServer({ skland: {…} })` | enables it and takes `storeFile`, `fetchImpl` (tests), `client`, `scanTimeoutMs`, `authSecret`, `publicOrigin`, `diagnosticKey`; `authNow` injects the test clock | + +`startServer` returns the live service as `srv.skland`, or `null` when the module is off; it logs +`[skland] account binding on — ` once. On an unconfigured server no store is created and no credential is read or +written. The off state still owns its paths: `routes.js` mounts `createSklandOffHandler`, so **every** `/skland/*` +request answers one uniform JSON `404 { ok: false, error }` — without it a GET would fall through to the static mount +(a 404) and a POST to the GET/HEAD gate (a 405 HTML page), two shapes for one API. + +## 3. The three login paths + +QR/SMS converge on `grant → cred` (`user/oauth2/v2/grant` then `zonai .../generate_cred_by_code`), then +`finishLogin` persists the row (`server/skland/service.js`). Password login instead queries the official account +binding service and stores its result without obtaining a Skland credential. + +The Skland QR path is retained for both official-server and Bilibili-server bindings; role extraction preserves +every Arknights channel returned by Skland. The phone/password form authenticates a Hypergryph account. Bilibili +players use Skland QR login and complete any authorization redirect in the provider's interface, as reported by the +user; the panel continues polling the same scan session. This integration does not assume that Bilibili offers a +phone/password endpoint, or that authorization is its only possible login method. + +| Path | The player | The server calls | Comes back | +|---|---|---|---| +| 扫码 | scans the QR (`scanUrl`, a `hypergryph://scan_login?…` deep link), confirms on the phone | `gen_scan/login` → `scan_status` (polled) → `token_by_scan_code` | `token`, `hgId`, **`deviceToken`** | +| 验证码 | receives the SMS code (`send_phone_code`, type 2) and types it | `token_by_phone_code` | `token`, `hgId`, **`deviceToken`** | +| 密码 | types phone + password | `token_by_phone_password` → binding-service grant (type 1) → `binding_list` | HG `token` and Arknights bindings; **no Skland cred required** | + +Password lookup follows the [official account SDK](https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js), +checked on 2026-10-14: `Account.API.grantApp('binding')` posts `{ token, appCode: 'be36d44aa36bfb5b', type: 1 }` +to `https://as.hypergryph.com/user/oauth2/v2/grant`, receives a service **token** (not a Skland grant code), then +GETs `https://binding-api-account-prod.hypergryph.com/account/binding/v1/binding_list` with that token and +`appCode=arknights`. A previous local QR/SMS login or stored `deviceToken` is not a prerequisite. +Only a real upstream status **109** (`NEED_DEVICE_VERIFY`) becomes `{ ok: false, error, needDevice: true }`; +an incorrect password remains its own error. The 密码 endpoint takes `phone` + `password` only; passwords are never stored. + +The [Arknights account page](https://ak.hypergryph.com/user/home) also uses this SDK. Its `/user/api/role/login` +request consumes an opaque role token issued by `/account/binding/v1/u8_token_by_uid`; the website does not locally +encrypt the password into that token. Reading the binding list already supplies the requested account information, +so this module does not need that later website-session login request or its analytics fields. + +`send_phone_code` uses **2** (`LOGIN_OR_REGISTER`) for both the initial send and a captcha retry, following the +[official account center login implementation](https://web.hycdn.cn/user-center/index.fa9aa713.js), checked on +2026-10-14. The previous type 24 came from an Endfield-specific example rather than this web login flow. +The endpoint may answer status 102 (发送频率太高) — a readable error, not a captcha. + +For `general/v1/scan_status`, both status **100** (未扫码) and **101** (已扫码待确认) return +`state: 'waiting'`, with `awaitingConfirmation: false/true` respectively. The title's scan tab automatically fetches +a QR and replaces it every 10 s. The first 101 starts a 60 s refresh window; repeated 101 responses do not extend it. +The manual 重新获取二维码 action stays available. Automatic/manual replacement keeps the existing image visible +until the new QR is encoded, then updates its source without removing the image. The same QR session keeps polling every 2 s, including while the phone confirmation propagates; +only the response carrying `data.scanCode` advances to the token exchange. Treating 101 as an error stopped the panel +before it could observe the user's confirmation. This mapping follows the [official account center](https://user.hypergryph.com/) +and its [login implementation](https://web.hycdn.cn/user-center/index.fa9aa713.js), checked on 2026-10-14. +Other failure statuses (including 102, expired on this endpoint) still surface as errors rather than waiting forever. +QR generation and polling are serial: confirmation wins over a queued refresh, and the latest status is checked +before automatic replacement. Leaving the scan tab or hiding the page pauses the timers; returning resumes the +same QR and checks its status before replacing it. Closing the dialog or binding successfully stops the timers. +Failures remain visible and retry QR generation after 10 s. The independent diagnostic page retains manual QR +generation; its compatible `waiting` state is unchanged. + +## 4. 人机验证 (geetest) + +SMS sending, password authentication and the official account grant can request verification as `status: 1` with +`data.captcha`. The client raises `SklandCaptchaError`; the service returns `{ ok: false, needCaptcha: true, captcha }`, +plus `captchaStep: 'password' | 'accountGrant'` for password login. + +The panel displays a verification button. After the user clicks it, `public/js/skland/captcha.js` lazily loads the +Geetest provider's widget. The user completes the challenge in that widget; cancellation and load errors leave a +retry button. No phone, password or account token is passed to Geetest by this module. Geetest v4 challenges use +`geetestId` and `riskType`; the provider's validation fields plus the original `challenge` are returned exactly as +the official HG SDK does. Legacy v3 challenges use `gt`, `challenge`, `new_captcha` and return the provider's three +`geetest_*` fields. The proof is sent in `captcha` on the original request, never generated by the server. + +Password retries include `captchaStep`. A challenge on `accountGrant` temporarily retains the authenticated HG token +in server memory, usable for 120 seconds, so its retry uses the same token without repeating the password exchange. +Pending tokens are never sent to the browser or persisted before login completes; unbind discards them. + +## 5. The HTTP surface + +### Game entry + +The title uses `/skland/session/` with `Authorization: Bearer ` and same-origin +cookies on every request, including reads and SMS sending. It establishes the existing `hello`/`welcome` transport +without entering the lobby. Invalid or expired transport tokens receive JSON 401. Tokens never appear in URLs, +QR codes or provider requests. Supplied `player`, UUID, UID or server fields cannot choose another account's row. +Before login, the server uses a reserved `@session/` pending row. After provider confirmation it copies +credentials to a random `@account/` row, clears the pending credentials and issues `sp_account`, a signed +HS256 JWT cookie: HttpOnly, SameSite=Strict, Path=/, Max-Age=2592000; HTTPS uses Secure. JWT contents are signed, +not encrypted, and contain only issuer/audience, random subject/login id, expiry and the selected role key. +Provider tokens, passwords and signing keys never enter it. Verification requires the fixed algorithm, signature, +issuer, audience, time bounds, matching server login record and an active binding. + +The following endpoints have the same request/response shapes as the diagnostic table below, with no `player` needed: +`status`, `roles`, `scan/start`, `scan/poll`, `phone/send`, `phone/login`, `password/login`, `unbind`. +The additional POST `role/select` accepts `{ roleKey }` and selects only an owned, named role with a nonempty UID +and region. `status`/selection also return `loginId`, `selectedRoleKey` and `gamePlayerId`. Selection is persisted +in the server login record and JWT. On login the default valid role is selected, otherwise the first valid role; +refresh preserves a still-valid selection or chooses a replacement. An empty list cannot start the game. +Changing roles or unbinding while in a room is rejected; leave first. The selected nickname uses the existing +callsign sanitizer, but never determines ownership. Binding itself does not press Start. + +`shared/accountIdentity.js` defines identity as the provider-reported server region (`channelName`) plus normalized +string UID. Optional `gameId` and mutable nickname are excluded, so QR and password answers may omit `gameId` +without changing identity. The game exposes a stable `a_` + base64url of the first 16 SHA-256 digest bytes of that tuple +as `playerId` (24 characters; `n:` fits the existing 32-character field id limit), uses the existing +registry/lobby maps, and permits duplicate nicknames. Different regions with the same UID remain distinct. +Authenticated WebSocket hello ignores client-claimed name/identity and verifies that the supplied reconnect token +belongs to this role; every room/game frame rechecks the live login and selected role. A bare reconnect token cannot +resume an account identity, and a remembered cookie alone establishes only login transport. An anonymous socket can +establish login transport and ping, but cannot issue room/game requests. Explicit entry is described below. + +New tabs and restarted servers restore through the persistent cookie/database without another provider login. +Unbind revokes the server login, clears its credentials/cookie and invalidates old JWTs, including open sockets; +expired cookies require binding again. Login/refresh replies arriving after unbind cannot restore cleared secrets. +Deleting the database or rotating the signing secret invalidates remembered logins. Room and match state remain +in memory and do not survive a server restart. + +### Cross-device match recovery + +The title's **恢复账号对局** reads server state for the selected role instead of the browser's recent-token ring. +A new device signs in independently and selects the same provider region + UID; it need not copy a reconnect token, +JWT or localStorage. Restoring the login cookie, opening the title and querying recovery do not take over the seat. + +These additional account routes use the same live transport header, JWT, same-origin checks and rate limit: + +| Method | Session endpoint | Request | Response | +|---|---|---|---| +| GET | `recovery` | none | `{ ok: true, recovery: null \| { id, name, code, mode, difficulty, inMatch, round, connected, spectating } }` | +| POST | `enter` | `{}` | `{ ok: true, gamePlayerId, token, name }`; starts a game transport or resumes the retained seat | +| POST | `resume` | `{ id }` from the recovery response | same as enter, but only while the selected role still owns that room/match | + +The recovery id is a selector, not a credential. It is unique to the in-memory room and match count; a new room or +later match invalidates it. Supplied player/UID/region/nickname fields never select another role. A missing, foreign +or stale recovery returns JSON 409 with `code: 'resume_expired'`, without creating a game session. A read does not +extend a disconnect deadline and contains no token or private match view. Entry/recovery credentials stay in the +POST response body, never in a URL or persistent browser storage before the server acknowledges them. + +After an explicit Start or recovery confirmation, the client stages that token, reconnects with the cookie and waits +for welcome to confirm the expected gamePlayerId before entering. The server keeps the same Match/PlayerState and +reuses the existing room/state resync. A successfully attached account socket rotates the reconnect token and closes +the old socket with 4001; obtaining the token without attaching does not kick the active device. Old tokens cannot +silently retake the seat. Automatic boot entry requires the tab's current token to have resumed the selected role; +a stale or duplicated tab stays at the title. Its user may still explicitly start/recover with a valid account login. +The connection banner's 在此页面继续 uses the same checked account entry, not an obsolete token. Any anonymous +welcome clears the entered flag, including a connection reset on an already open page. +Ordinary reconnects retain the existing live-tab claim checks. Module-off sessions do not rotate their tokens. + +The existing lifetimes still apply: solo matches keep a disconnected seat for 24 hours; co-op matches for 10 minutes; +a waiting room also has its 60-second lobby grace. Actual expiration cleans the old seat through lobby.onExpire +before the stable role id can be reused. Changing devices does not save the match to SQLite, synchronize local UI +preferences/statistics, or make it survive server restart. The UI supplies loading, empty, stale/error and refresh +states and warns when continuing may disconnect another device. + +### Independent diagnostic page + +These routes require `SP_SKLAND_DIAGNOSTIC_KEY` and the matching `X-SP-Diagnostic-Key` header; otherwise they +return 403. Do not embed this administrator key in browser code. `skland.html` is a development diagnostic page; +the main title uses the account-scoped endpoints above. Reserved `@session/` and `@account/` ids are forbidden. + +Every answer is JSON, including errors (`{ ok: false, error }`), so a client is never handed the module-level HTML +error page. Bodies are capped at 64 KiB. The handler is handed requests before the GET/HEAD gate, so it owns POST. + +| Method | Path | Request | Response | +|---|---|---|---| +| GET | `/skland/status` | `?player=` | `{ playerId, status, bound, nickname, hgId, roles, error? }` | +| GET | `/skland/roles` | `?player=&refresh=1` | `{ ok, roles }` (200 even on failure) | +| POST | `/skland/scan/start` | `{ player }` | `{ ok, scanId, scanUrl, expiresInMs }` | +| POST | `/skland/scan/poll` | `{ player }` | `{ ok, state: none \| waiting \| confirmed \| expired \| error, scanUrl?, awaitingConfirmation?, status?, nickname?, error? }` | +| POST | `/skland/phone/send` | `{ player?, phone, captcha? }` | `{ ok }` or `{ ok: false, needCaptcha, captcha }` or `{ ok: false, error }` | +| POST | `/skland/phone/login` | `{ player, phone, code }` | `{ ok, nickname, roles }` or `{ ok: false, error }` | +| POST | `/skland/password/login` | `{ player, phone, password, captcha?, captchaStep? }` | `{ ok, nickname, roles }`, `{ ok: false, needCaptcha, captcha, captchaStep }` or `{ ok: false, error, needDevice? }` | +| POST | `/skland/unbind` | `{ player }` | `{ ok: true }` | + +`status` is `unbound | scanning | bound | error`; `bound` is `status === 'bound'`. A missing `player` (or phone / +code / password, per endpoint) is a 400, an unknown `/skland/*` path a 404, a non-POST method on a POST path a 405, and a +malformed body a 400. One 扫码 poll both reports `confirmed` and completes the login; a poll with no live session is +`state: 'none'`. + +```bash +curl -s 'http://127.0.0.1:3000/skland/status?player=p1' -H 'X-SP-Diagnostic-Key: ' +curl -s -X POST http://127.0.0.1:3000/skland/phone/login \ + -H 'X-SP-Diagnostic-Key: ' -H 'content-type: application/json' -d '{"player":"p1","phone":"13800000000","code":"123456"}' +``` + +## 6. The database + +One row per player in `skland_players` (`node:sqlite`, `journal_mode = WAL`, `synchronous = NORMAL`), file from +`SP_SKLAND_DB` / `storeFile` (default `data/skland.db`; the parent directory is created). The store is synchronous; +callers never await. The enabled module requires Node with `node:sqlite` (22.5 or newer); it does not silently +replace persistent authentication with an in-memory fallback. + +| Column | Holds | +|---|---| +| `player_id` | reserved pending/account subject key, or administrator diagnostic id — primary key | +| `access_token` | the HG login token (refreshes official bindings or rebuilds a Skland `cred`) | +| `device_token` | the device token of a 扫码/验证码 login (optional on Skland grants; unused by official account lookup) | +| `hg_id` | the Hypergryph account id (`hgId`) | +| `cred` | the long-term API credential | +| `cred_token` | the HMAC signing key — never leaves the server | +| `user_id` | the Skland internal user id (the column exists; no code path writes it) | +| `d_id` | the device fingerprint the credentials are pinned to (`B` + 32 hex, per player) | +| `nickname` | the profile name from the binding answer | +| `binding` | the raw binding list from the selected provider (cache; parsed back on read) | +| `binding_source` | `skland` for QR/SMS, `hypergryph` for official password lookup; selects the refresh path | +| `status` | `unbound \| scanning \| bound \| error` | +| `error` | the last failure message, if any | +| `created_at`, `updated_at` | epoch ms | + +`update()` merges only the named fields; `remove()` drops the row. **解绑 (`unbind`) clears every credential field — +including `device_token` and `d_id` — and marks the row `unbound`.** The row itself stays. Password login can be +attempted again directly. Existing databases receive the `binding_source` column automatically, defaulting their +rows to `skland` so the already working QR/SMS bindings continue to refresh through their original provider. +Successful password lookup clears any previous Skland credentials and stores only the HG account token and bindings. + +The same SQLite file holds `account_meta` (the generated JWT signing secret) and `account_logins` +(`id`, `subject`, `role_key`, `expires`, `revoked`). These tables enforce expiry, selection and logout revocation. +Back up the file consistently with its WAL state and treat backups as credentials too. + +## 7. Credential refresh + +Official password bindings refresh by obtaining a new binding-service token from the stored HG account token and +querying the official binding list again. An expired HG token requires login again. QR/SMS `getBinding` retries +once on the two expiry codes, otherwise the player rebinds: + +- **10000** — `cred_token` expired → `POST zonai /api/v1/auth/refresh` (header `cred`), retry with the new `token`. +- **10002** — `cred` expired → re-run `getGrantCode(access_token, device_token)` then `getCred`, retry. Needs a stored + `access_token` or `device_token`; without either it falls through to the error below. +- Any other code, or a failed retry: the upstream message is raised; one matching 未登录 reads as + `登录已过期,请重新绑定`. `service.roles(player, true)` re-queries only while `status === 'bound'` and persists the + refreshed credentials. + +## 8. Signing + +`server/skland/crypto.js` is pure and fully unit-tested against pinned vectors (`test/skland/crypto.test.js`) — the +composition is the part a refactor breaks silently. With `header_ca = {"platform":"3","timestamp":,"dId":, +"vName":"1.0.0"}` in that key order and `ts = floor(now/1000) - 2` (2 s behind): + +``` +GET: raw = path + query + ts + compact(header_ca) +POST: raw = path + compact(body ?? {}) + ts + compact(header_ca) +sign = md5( hmacSha256_hex(cred_token, raw) ) // SHA-256 HMAC, lowercase hex, then MD5 of that hex +``` + +The headers are `cred`, `dId`, `platform: 3`, `timestamp`, `vName: 1.0.0`, `sign` plus the Skland mobile client +identity (`User-Agent: Skland/…`, `X-Requested-With: com.hypergryph.skland`, `Accept-Encoding: gzip`). Only +`getBinding` / the exposed `signedGet` / `signedPost` are signed; `generate_cred_by_code` is not. The login endpoints +send `deviceHeaders()` instead — a `X-DeviceId` (10 hex chars), `X-DeviceModel` and `X-DeviceType: 2`, cached for the +process so the upstream sees one device. + +## 9. 安全模型 (security model) + +- **Ownership comes from server-verified credentials.** Account routes need a live transport token plus the JWT for + account operations. UUID/UID/region edits do not confer ownership; role selection is checked against stored + provider bindings. WebSocket identity and authorization are server-enforced too. Administrator diagnostic routes + are closed by default and cannot access reserved rows. No arbitrary upstream URL/proxy endpoint is exposed. +- **Same-origin requests and bounded traffic.** Cross-site Origin/Fetch Metadata requests are rejected. Account HTTP + requests share a per-address token bucket (3/s, burst 120); request bodies are capped at 64 KiB. Use HTTPS for + remote deployments. A fully stolen valid bearer/cookie remains a credential until expiry/revocation; JWT signing + prevents modification, not copying. HttpOnly keeps the JWT out of JavaScript storage. +- **A `cred` (with `cred_token`) is equivalent to account access.** It is a long-term API credential, not a session + cookie. Treat it as a secret. +- **The store keeps credentials in plaintext SQLite.** A deployment must treat the database file as a secret: restrict + its permissions to the server account, keep it out of backups and repositories, and **never serve or expose it over + HTTP**. The built-in static/media/pack handlers deny the configured DB, WAL and SHM paths, including realpath + aliases and Windows filename casing. A reverse proxy or other file server needs its own protection. This is why + the module is opt-in. +- A player can **解绑** after leaving their room; that clears the stored credentials. Changing the Hypergryph password invalidates + the stored credentials upstream, so the player rebinds. + +## 10. Legal and project rules + +The account flow follows the public official SDK; the legacy Skland flow also follows community descriptions. +These website endpoints have no compatibility guarantee and may change without notice. The `appCode` in `crypto.js` is a public SDK constant visible in a +decompiled client — it is **not** an authorisation or a permission. This project is non-commercial +([AGENTS.md](../AGENTS.md) forbids ads, payments and other monetisation) and stays that way. Enabling the module, and +the handling of the credentials it stores, is the operator's decision and their responsibility. + +## 11. Tests + +Account test files (`node --test 'test/skland/*.test.js'`), against a stub upstream or widget — no account network. + +| File | Pins | +|---|---| +| `test/skland/crypto.test.js` (10) | the signature composition (GET/POST, with and without query/body), the primitives, stability under a single changed input, the client identity headers, `makeDeviceId`, the cached `deviceHeaders` | +| `test/skland/store.test.js` (10) | one row per player, full credential round-trip, merge-only update, unbind clearing credentials, WAL persistence, parent-dir creation, and migration preserving old QR credentials while persisting the official binding source | +| `test/skland/client.test.js` (21) | QR/SMS chains, 100→101→confirmed polling, captcha retry, password → official binding-service grant/list without Skland, real device challenge 109, malformed grant/list and upstream errors, signed Skland requests and credential refresh | +| `test/skland/http.test.js` (24) | server wiring, QR polling with official/Bilibili channel preservation, SMS/captcha, fresh password login without Skland credentials, password/grant captcha continuation on the same token, pending-login invalidation on unbind, actual device challenge and incorrect password, provider-aware role refresh and switching, player isolation and error responses | +| `test/skland/captcha.test.js` (4) | fake Geetest v3/v4 widgets: exact proof/challenge hand-off, cancellation, missing proof, unsupported challenge and provider load errors; no real challenge or network | +| `test/skland/docker.test.js` (8) | the one-command packaging: the compose service, the port, `SP_SKLAND`/`SP_SKLAND_DB`, the database path inside the mounted named volume (and outside every static mount), the Dockerfile's prepared+owned directory before `USER node` | +| `test/skland/panel.test.js` (10) | `public/skland.html`: every endpoint it calls, the QR encoding `scanUrl` (not the scanId), the 2 s poll with its cleanup, explicit poll errors, the `needCaptcha` path and the "not enabled" answer | +| `test/skland/api.test.js` (5) | the title adapter's token-only authorization header, error mapping, hello reuse and default/selected role choice | +| `test/skland/scan.test.js` (7) | deterministic 10/60 s refresh boundaries, serial polling, manual refresh, pause/resume, late responses, completion retry and failure recovery | +| `test/skland/session.test.js` (5) | JWT signature/claims, role ownership, cross-origin rejection, logout/replay, restart/expiry, DB download denial, late provider cancellation, same-name UID/region isolation and authenticated game transport | +| `test/skland/entry.browser.test.js` (1, opt-in) | the real title/modal with a stub provider: QR, role/channel selection, password CAPTCHA, SMS, hidden/plain-text callsign, empty roles, unbinding, cached-entry checks, reload persistence and short landscape layout; skipped unless `SP_E2E=1` | +| `test/skland/recovery.test.js` | independent logins restoring the same live Match/private state, UID/region and role-selection isolation, read-only lookup, explicit takeover/token rotation, stale selectors, unchanged solo/co-op windows, expired-seat cleanup and account-id compatibility with watch/kick/battle reports | +| `test/skland/recovery.browser.test.js` (1, opt-in) | two isolated browser contexts with no shared saves: real title/login/recovery, exact state restoration, manual continue, empty/expired recovery, old-device reload and duplicated-tab safety, desktop/landscape-phone layout; `SP_E2E=1` | + +## 12. Files + +| File | Holds | +|---|---| +| `server/skland/crypto.js` | the signature, the appCodes, the device id and the login device headers | +| `server/skland/client.js` | the three login chains, captcha, `getGrantCode`/`getCred`, the signed client, `getBinding` refresh | +| `server/skland/store.js` | the SQLite credential schema, migrations and merge-only store | +| `server/skland/service.js` | per-player orchestration, scan sessions, status/roles, `finishLogin`, `unbind` | +| `server/skland/http.js` | administrator diagnostic JSON routes and bounded body reader | +| `server/skland/auth.js`, `accountHttp.js` | persistent JWT/account ownership, revocation, stable game identity and scoped HTTP entry/recovery | +| `server/skland/recovery.js` | read-only selected-role seat metadata and room/match recovery selectors | +| `shared/accountIdentity.js` | shared UID + server-region key, independent of nickname and optional provider metadata | +| `public/js/skland/captcha.js` | user-operated Geetest widget, loaded only when verification is needed | +| `public/js/skland/entry.js`, `api.js` | title binding button/dialog, token-authenticated client, role selection and QR loading | +| `public/js/skland/gate.js`, `resume.js` | checked account entry, safe automatic reconnect and the cross-device recovery picker | +| `public/js/skland/scan.js` | automatic QR generation, 10/60 s refresh and serial 2 s confirmation polling | +| `public/css/skland/binding.css` | feature-scoped styles, loaded by the entry without editing global stylesheets | +| `server/skland/session.js` | reconnect-token resolver and the reserved game-binding namespace | + +## 13. Keeping upstream merges small + +Login logic, the callsign display, entry validation and CSS live under the module's own directories. The title +renders these components, owns the dialog-open state and chooses 绑定账号/开始 from the verified nickname. `main.js` +checks cached entry through `restoreBoundEntry`. The title mounts the account recovery picker; the obsolete local +shortcut is removed from the lobby (the generic `ui/resumeMatch.js` helper remains available upstream). The identity +helper stages explicit account tokens without persisting a claim before welcome. The server factory installs an +optional account policy: small hooks in `server/net.js` cover connection, hello, successful attachment/token rotation +and per-frame authorization; static handlers accept a private-file denylist. With no policy the upstream transport +behaves as before. No server lobby, match, simulation or WebSocket message changes are needed. +Language packs and this reference hold translations/documentation. +Keep future account changes in these module files and preserve the small title/boot/recovery hooks when merging +upstream. This reduces the conflict surface; edits to the same hooks/translations may still need a merge. + +The Hypergryph and Bilibili brand images are referenced directly from the +[official account page bundle](https://web.hycdn.cn/arknights/user/index.842215.js): +[`hypergryph.d69547.png`](https://web.hycdn.cn/arknights/user/assets/imgs/hypergryph.d69547.png) and +[`bilibili.11810a.png`](https://web.hycdn.cn/arknights/user/assets/imgs/bilibili.11810a.png), checked on 2026-10-14. +Images are decorative beside text labels, so a failed image request leaves the controls usable. No game art is added +to git. The human-operated CAPTCHA temporarily releases the game's modal focus trap for the provider widget. diff --git a/package-lock.json b/package-lock.json index 3261c141..fb184437 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "pixi-spine": "^4.0.6", "pixi.js": "^7.4.2", "preact": "^10.29.8", + "qrcode-generator": "^2.0.4", "three": "^0.186.1", "ws": "^8.22.0" }, @@ -28,6 +29,231 @@ "node": ">=22" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@pixi-spine/base": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@pixi-spine/base/-/base-4.0.6.tgz", @@ -324,6 +550,63 @@ "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, "node_modules/ansi-regex": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", @@ -350,6 +633,43 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -429,6 +749,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/devtools-protocol": { "version": "0.0.1687809", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1687809.tgz", @@ -503,12 +863,226 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -578,6 +1152,29 @@ "node": ">= 0.4" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -602,6 +1199,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -614,18 +1224,126 @@ "node": ">= 0.4" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/htm": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz", "integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==", "license": "Apache-2.0" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/ismobilejs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -635,6 +1353,19 @@ "node": ">= 0.4" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -652,6 +1383,20 @@ "node": ">=18.0.0" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -664,6 +1409,70 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pixi-spine": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pixi-spine/-/pixi-spine-4.0.6.tgz", @@ -1123,6 +1932,16 @@ } } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", @@ -1147,6 +1966,32 @@ "node": ">=22.12.0" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", @@ -1163,6 +2008,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -1274,6 +2142,19 @@ "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==", "license": "MIT" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typed-query-selector": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", @@ -1281,6 +2162,40 @@ "dev": true, "license": "MIT" }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/url": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", @@ -1301,6 +2216,32 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -1396,892 +2337,6 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/eslint": { - "version": "10.12.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", - "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^11.2.0", - "ignore": "^5.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "find-up": "^5.0.0", - "is-glob": "^4.0.0", - "minimatch": "^10.2.5", - "optionator": "^0.9.3", - "cross-spawn": "^7.0.6", - "glob-parent": "^6.0.2", - "imurmurhash": "^0.1.4", - "@eslint/core": "^1.2.1", - "eslint-scope": "^9.1.2", - "@humanfs/node": "^0.16.6", - "@types/estree": "^1.0.6", - "fast-deep-equal": "^3.1.3", - "natural-compare": "^1.4.0", - "file-entry-cache": "11.1.5 || >11.1.6 <12", - "@eslint/plugin-kit": "^0.7.3", - "eslint-visitor-keys": "^5.0.1", - "@eslint/config-array": "^0.23.5", - "@humanwhocodes/retry": "^0.4.2", - "escape-string-regexp": "^4.0.0", - "@eslint/config-helpers": "^0.7.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint-community/eslint-utils": "^4.8.0", - "@humanwhocodes/module-importer": "^1.0.1", - "json-stable-stringify-without-jsonify": "^1.0.1" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/globals": { - "version": "17.13.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", - "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "uri-js": "^4.2.2", - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^0.4.1", - "fast-json-stable-stringify": "^2.0.0" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "deep-is": "^0.1.3", - "word-wrap": "^1.2.5", - "type-check": "^0.4.0", - "levn": "^0.4.1", - "fast-levenshtein": "^2.0.6" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "which": "^2.0.1", - "path-key": "^3.1.0", - "shebang-command": "^2.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/file-entry-cache": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", - "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^6.1.23" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", - "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "levn": "^0.4.1", - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", - "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - } - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "./bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/flat-cache": { - "version": "6.1.23", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", - "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.4.2", - "cacheable": "^2.5.0", - "hookified": "^1.15.0" - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/uri-js/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", - "dev": true, - "license": "ISC" - }, - "node_modules/cacheable": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", - "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "keyv": "^5.6.0", - "qified": "^0.10.1", - "hookified": "^1.15.0", - "@cacheable/utils": "^2.5.0", - "@cacheable/memory": "^2.2.0" - } - }, - "node_modules/hookified": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", - "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, - "node_modules/qified": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", - "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hookified": "^2.1.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@cacheable/utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", - "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "keyv": "^5.6.0", - "hashery": "^1.5.1" - } - }, - "node_modules/@cacheable/memory": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", - "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "keyv": "^5.6.0", - "hookified": "^1.15.1", - "@keyv/bigmap": "^1.3.1", - "@cacheable/utils": "^2.5.0" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -2292,62 +2347,14 @@ "node": ">=10" } }, - "node_modules/@keyv/serialize": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", - "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/qified/node_modules/hookified": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", - "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", - "dev": true, - "license": "MIT" - }, - "node_modules/hashery": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", - "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "dependencies": { - "hookified": "^1.15.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@keyv/bigmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", - "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "hashery": "^1.4.0", - "hookified": "^1.15.0" - }, - "peerDependencies": { - "keyv": "^5.6.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" + "funding": { + "url": "https://github.com/sponsors/colinhacks" } } } diff --git a/package.json b/package.json index 082fdefd..cbdc2576 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "pixi-spine": "^4.0.6", "pixi.js": "^7.4.2", "preact": "^10.29.8", + "qrcode-generator": "^2.0.4", "three": "^0.186.1", "ws": "^8.22.0" }, diff --git a/public/css/skland/binding.css b/public/css/skland/binding.css new file mode 100644 index 00000000..21e97609 --- /dev/null +++ b/public/css/skland/binding.css @@ -0,0 +1,45 @@ +/* Feature-local styles: keep upstream title/components styles untouched. */ +.sp-binding-callsign { display: grid; gap: .1rem; margin: 0; } +.sp-binding-callsign__label { display: flex; align-items: baseline; gap: .1rem; color: var(--text-lo); font-size: calc(.15 * var(--t)); font-weight: 500; letter-spacing: .04em; } +.sp-binding-callsign__name { display: flex; align-items: center; gap: .12rem; margin: 0; color: var(--text-hi); font-size: calc(.26 * var(--t)); font-weight: 700; line-height: 1.4; overflow-wrap: anywhere; } +.sp-binding-callsign__icon { flex: none; color: var(--text-lo); } +.sp-binding-callsign__name > span { min-width: 0; } +.sp-binding-entry { display: flex; justify-content: flex-end; width: 4.9rem; margin-top: -.08rem; } +.sp-binding-entry:empty { display: none; } +.sp-binding-entry > .btn { max-width: 100%; white-space: normal; text-align: left; } +.sp-binding .modal__body { display: grid; gap: .2rem; font-size: calc(.18 * var(--t)); } +.sp-binding__intro { color: var(--text-lo); line-height: 1.6; margin: 0; } +.sp-binding__tabs, .sp-binding__channels, .sp-binding__actions { display: flex; flex-wrap: wrap; align-items: center; gap: .12rem; } +.sp-binding__channels { color: var(--text-lo); font-size: calc(.15 * var(--t)); } +.sp-binding__channels > span { display: inline-flex; align-items: center; gap: .08rem; } +.sp-binding__brand { width: .26rem; height: .26rem; object-fit: contain; flex: none; } +.sp-binding__scan { display: grid; justify-items: center; gap: .16rem; text-align: center; } +.sp-binding__scan p { margin: 0; } +.sp-binding__qr { width: 2.4rem; height: 2.4rem; max-width: 100%; image-rendering: pixelated; background: #fff; } +.sp-binding__form, .sp-binding__roles { display: grid; gap: .16rem; } +.sp-binding__form p, .sp-binding__roles p { margin: 0; line-height: 1.6; } +.sp-binding__code { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: .12rem; } +.sp-binding__field { min-width: 0; } +.sp-binding__role { justify-content: flex-start; gap: .14rem; height: auto; min-height: .65rem; text-align: left; white-space: normal; } +.sp-binding__role > span { display: grid; gap: .04rem; overflow-wrap: anywhere; } +.sp-binding__role small { color: var(--text-lo); font-size: calc(.14 * var(--t)); } +.sp-binding__error { color: var(--red); overflow-wrap: anywhere; } +.sp-binding__notice { color: var(--mint-500); } +.sp-binding__captcha { border-top: 1px solid var(--line-2); padding-top: .16rem; } +.sp-binding__verifying { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; background: rgba(0, 0, 0, .75); color: var(--text-hi); font-size: calc(.2 * var(--t)); } + +/* Short landscape screens: the title scene can be taller than the browser's visual viewport. Anchor the account box + to the top of the overlay and let its body scroll, so the close action and role list remain reachable. */ +@media (max-height: 600px) { + .sp-binding { align-self: start; max-height: calc(100dvh - .3rem); margin-top: .15rem; } + .sp-binding .modal__head { padding-top: .12rem; } + .sp-binding .modal__body { padding-top: .1rem; padding-bottom: .14rem; } + .sp-binding .modal__actions { padding-top: .1rem; padding-bottom: .1rem; } +} + +/* Recovery remains readable on another device even at the game's smallest landscape root scale. */ +.sp-account-resume { width: min(94vw, max(6.8rem, 420px)); max-height: calc(100dvh - 24px); } +.sp-account-resume .modal__title { font-size: max(18px, calc(.26 * var(--t))); } +.sp-account-resume .modal__body { display: grid; gap: max(.16rem, 10px); font-size: max(14px, calc(.18 * var(--t))); line-height: 1.7; } +.sp-account-resume .modal__body p { margin: 0; } +.sp-account-resume .btn { min-height: 44px; font-size: max(14px, calc(.17 * var(--t))); } diff --git a/public/i18n/en.json b/public/i18n/en.json index 85fef76a..5ca99ec0 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1218,5 +1218,58 @@ "{names}只能部署在召唤者相邻四格,已退回整备区": "{names} can only be deployed on the four tiles adjacent to the summoner and returned to the Bench", "{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names} can only be deployed on the four tiles adjacent to the summoner; the Bench is full, so they come back next round", "装备库存不足,未获得该装备": "Equipment stock is insufficient; no equipment was received", - "库存不足": "Out of stock" + "库存不足": "Out of stock", + "需要人机验证,请点击下方按钮完成验证。": "Human verification is required. Use the button below to continue.", + "开始人机验证": "Start verification", + "正在加载验证组件…": "Loading verification…", + "验证已取消,请点击按钮重试。": "Verification cancelled. Use the button to try again.", + "验证通过,正在继续…": "Verified. Continuing…", + "暂不支持此验证类型,请使用验证码或扫码登录。": "This verification type is not supported. Use SMS or QR login.", + "验证组件加载失败,请重试。": "Could not load verification. Please try again.", + "验证未完成,请重试。": "Verification was not completed. Please try again.", + "官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "For the official server, you can log in with your phone number. For the Bilibili server, scan with Skland and follow the authorization prompts.", + "此服务器未启用账号绑定,请联系服主开启后再进入。": "Account binding is disabled on this server. Ask the host to enable it before entering.", + "游戏会话已失效,请关闭后重试。": "Your game session expired. Close this dialog and try again.", + "连接失败,请重试。": "Connection failed. Please try again.", + "二维码加载失败,请重试。": "Could not load the QR code. Please try again.", + "服务器响应异常,请重试。": "Unexpected server response. Please try again.", + "已绑定:{name}": "Bound: {name}", + "绑定明日方舟账号": "Bind Arknights account", + "登录成功,未找到明日方舟角色。": "Logged in, but no Arknights characters were found.", + "登录失败,请重试。": "Login failed. Please try again.", + "验证码已发送,请查看短信。": "Code sent. Check your text messages.", + "二维码已失效,请重新获取。": "The QR code expired. Get a new code.", + "请在验证窗口中完成人机验证。": "Complete human verification in the verification window.", + "绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "Your character nickname becomes your callsign after binding. You can start once it is retrieved.", + "绑定账号后自动获取": "Retrieved after binding your account", + "绑定账号": "Bind account", + "· 绑定账号并开始后将自动加入": "· Join automatically after binding your account and starting", + "选择明日方舟角色": "Select an Arknights character", + "刷新角色": "Refresh characters", + "解绑账号": "Unbind account", + "登录方式": "Login method", + "森空岛扫码": "Skland QR login", + "验证码": "Verification code", + "密码": "Password", + "官服": "Official server", + "B服:扫码后按提示完成授权": "Bilibili: scan and follow the authorization prompts", + "使用森空岛 App 扫描二维码": "Scan the QR code with the Skland app", + "使用森空岛 App 扫码并确认,支持官服和 B 服。": "Scan and confirm with the Skland app. Supports the official and Bilibili servers.", + "重新获取二维码": "Get a new QR code", + "二维码每 10 秒自动刷新,也可手动刷新。": "The QR code refreshes every 10 seconds. You can also refresh it manually.", + "已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "Scanned. Waiting for confirmation; the QR code will refresh automatically after 1 minute.", + "获取二维码": "Get QR code", + "使用鹰角账号登录官服。B服请选择森空岛扫码。": "Use a Hypergryph account for the official server. For Bilibili, choose Skland QR login.", + "手机号": "Phone number", + "{n} 秒后重发": "Resend in {n} seconds", + "发送验证码": "Send code", + "登录并绑定": "Log in and bind", + "恢复账号对局": "Resume Account Match", + "登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "Sign in to the same account and select the same server and character to continue an unexpired match on another device. Matches cannot be restored after a server restart.", + "正在查询账号对局…": "Checking for account matches…", + "此对局已结束或过期,请刷新后重试。": "This match has ended or expired. Refresh and try again.", + "当前角色没有可恢复的对局。": "There is no match to resume for the selected character.", + "对局进行中": "Match in progress", + "同盟等候中": "Waiting in the alliance", + "如果此角色仍在其他设备在线,恢复后将断开旧连接。": "If this character is still online on another device, resuming will disconnect that device." } diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 9dd7294c..70e303d6 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -1224,5 +1224,58 @@ "{names}只能部署在召唤者相邻四格,已退回整备区": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能なため、ベンチに戻りました", "{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能です。ベンチが満員のため、次のラウンドに返却されます", "装备库存不足,未获得该装备": "装備の在庫が不足しているため、獲得できませんでした", - "库存不足": "在庫不足" + "库存不足": "在庫不足", + "需要人机验证,请点击下方按钮完成验证。": "本人確認が必要です。下のボタンから認証を完了してください。", + "开始人机验证": "認証を開始", + "正在加载验证组件…": "認証を読み込み中…", + "验证已取消,请点击按钮重试。": "認証をキャンセルしました。ボタンから再試行してください。", + "验证通过,正在继续…": "認証が完了しました。処理を続けています…", + "暂不支持此验证类型,请使用验证码或扫码登录。": "この認証形式には対応していません。SMSまたはQRコードでログインしてください。", + "验证组件加载失败,请重试。": "認証を読み込めませんでした。再試行してください。", + "验证未完成,请重试。": "認証が完了していません。再試行してください。", + "官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "公式サーバーは電話番号でログインできます。Bilibiliサーバーは森空島でQRコードを読み取り、画面の案内に従って連携を許可してください。", + "此服务器未启用账号绑定,请联系服主开启后再进入。": "このサーバーではアカウント連携が無効です。管理者に有効化を依頼してから入場してください。", + "游戏会话已失效,请关闭后重试。": "ゲームセッションが無効です。閉じてから再試行してください。", + "连接失败,请重试。": "接続に失敗しました。再試行してください。", + "二维码加载失败,请重试。": "QRコードを読み込めませんでした。再試行してください。", + "服务器响应异常,请重试。": "サーバーの応答が異常です。再試行してください。", + "已绑定:{name}": "連携済み:{name}", + "绑定明日方舟账号": "アークナイツのアカウントを連携", + "登录成功,未找到明日方舟角色。": "ログインしましたが、アークナイツのキャラクターが見つかりません。", + "登录失败,请重试。": "ログインに失敗しました。再試行してください。", + "验证码已发送,请查看短信。": "認証コードを送信しました。SMSを確認してください。", + "二维码已失效,请重新获取。": "QRコードの有効期限が切れました。再取得してください。", + "请在验证窗口中完成人机验证。": "認証ウィンドウで認証を完了してください。", + "绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "連携後、キャラクター名をコードネームとして自動取得します。取得できたら開始できます。", + "绑定账号后自动获取": "アカウント連携後に自動取得", + "绑定账号": "アカウント連携", + "· 绑定账号并开始后将自动加入": "· アカウント連携後、開始すると自動で参加します", + "选择明日方舟角色": "アークナイツのキャラクターを選択", + "刷新角色": "キャラクターを更新", + "解绑账号": "アカウント連携を解除", + "登录方式": "ログイン方法", + "森空岛扫码": "森空島でQRログイン", + "验证码": "認証コード", + "密码": "パスワード", + "官服": "公式サーバー", + "B服:扫码后按提示完成授权": "Bilibili:QR読取後に案内に従って連携を許可", + "使用森空岛 App 扫描二维码": "森空島アプリでQRコードを読み取る", + "使用森空岛 App 扫码并确认,支持官服和 B 服。": "森空島アプリでQRコードを読み取り、確認してください。公式・Bilibiliサーバーに対応しています。", + "重新获取二维码": "QRコードを再取得", + "二维码每 10 秒自动刷新,也可手动刷新。": "QRコードは10秒ごとに自動更新されます。手動でも更新できます。", + "已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "読み取り済み、確認待ちです。QRコードは1分後に自動更新されます。", + "获取二维码": "QRコードを取得", + "使用鹰角账号登录官服。B服请选择森空岛扫码。": "公式サーバーはHypergryphアカウントでログインしてください。Bilibiliは森空島のQRログインを選択してください。", + "手机号": "電話番号", + "{n} 秒后重发": "{n} 秒後に再送信", + "发送验证码": "認証コードを送信", + "登录并绑定": "ログインして連携", + "恢复账号对局": "アカウントの対局を再開", + "登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "同じアカウントでログインし、同じサーバーのキャラクターを選択すると、有効期限内の対局を別の端末で続行できます。サーバー再起動後は復元できません。", + "正在查询账号对局…": "アカウントの対局を確認中…", + "此对局已结束或过期,请刷新后重试。": "この対局は終了したか、有効期限が切れています。更新して再試行してください。", + "当前角色没有可恢复的对局。": "選択中のキャラクターには再開できる対局がありません。", + "对局进行中": "対局進行中", + "同盟等候中": "同盟で待機中", + "如果此角色仍在其他设备在线,恢复后将断开旧连接。": "このキャラクターが別の端末でオンラインの場合、再開すると以前の接続は切断されます。" } diff --git a/public/i18n/ko.json b/public/i18n/ko.json index 4c31399f..a39bdb60 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -1220,5 +1220,58 @@ "{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다.", "装备库存不足,未获得该装备": "장비 재고가 부족하여 획득하지 못했습니다.", "库存不足": "재고 부족", - "渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "렌더러를 불러오지 못해 간소화 화면으로 전환했습니다(모든 기능을 사용할 수 있으며 새로고침하면 다시 시도합니다)." + "渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "렌더러를 불러오지 못해 간소화 화면으로 전환했습니다(모든 기능을 사용할 수 있으며 새로고침하면 다시 시도합니다).", + "需要人机验证,请点击下方按钮完成验证。": "자동 입력 방지 인증이 필요합니다. 아래 버튼을 눌러 인증해 주세요.", + "开始人机验证": "인증 시작", + "正在加载验证组件…": "인증을 불러오는 중…", + "验证已取消,请点击按钮重试。": "인증이 취소되었습니다. 버튼을 눌러 다시 시도해 주세요.", + "验证通过,正在继续…": "인증이 완료되어 계속 진행합니다…", + "暂不支持此验证类型,请使用验证码或扫码登录。": "지원하지 않는 인증 방식입니다. SMS 또는 QR 코드로 로그인해 주세요.", + "验证组件加载失败,请重试。": "인증을 불러오지 못했습니다. 다시 시도해 주세요.", + "验证未完成,请重试。": "인증을 완료하지 못했습니다. 다시 시도해 주세요.", + "官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "공식 서버는 휴대폰 번호로 로그인할 수 있습니다. Bilibili 서버는 스카이랜드로 QR 코드를 스캔하고 화면 안내에 따라 연동을 승인해 주세요.", + "此服务器未启用账号绑定,请联系服主开启后再进入。": "이 서버에서는 계정 연동이 비활성화되어 있습니다. 서버 관리자에게 활성화를 요청한 뒤 입장하세요.", + "游戏会话已失效,请关闭后重试。": "게임 세션이 만료되었습니다. 창을 닫고 다시 시도해 주세요.", + "连接失败,请重试。": "연결하지 못했습니다. 다시 시도해 주세요.", + "二维码加载失败,请重试。": "QR 코드를 불러오지 못했습니다. 다시 시도해 주세요.", + "服务器响应异常,请重试。": "서버 응답에 문제가 있습니다. 다시 시도해 주세요.", + "已绑定:{name}": "연동됨: {name}", + "绑定明日方舟账号": "명일방주 계정 연동", + "登录成功,未找到明日方舟角色。": "로그인했으나 명일방주 캐릭터를 찾지 못했습니다.", + "登录失败,请重试。": "로그인하지 못했습니다. 다시 시도해 주세요.", + "验证码已发送,请查看短信。": "인증 코드를 보냈습니다. 문자 메시지를 확인하세요.", + "二维码已失效,请重新获取。": "QR 코드가 만료되었습니다. 새로 발급해 주세요.", + "请在验证窗口中完成人机验证。": "인증 창에서 사람 인증을 완료해 주세요.", + "绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "연동 후 캐릭터 닉네임을 코드네임으로 자동으로 가져옵니다. 가져오면 시작할 수 있습니다.", + "绑定账号后自动获取": "계정 연동 후 자동으로 가져옵니다", + "绑定账号": "계정 연동", + "· 绑定账号并开始后将自动加入": "· 계정 연동 후 시작하면 자동으로 참가합니다", + "选择明日方舟角色": "명일방주 캐릭터 선택", + "刷新角色": "캐릭터 새로고침", + "解绑账号": "계정 연동 해제", + "登录方式": "로그인 방법", + "森空岛扫码": "스카이랜드 QR 로그인", + "验证码": "인증 코드", + "密码": "비밀번호", + "官服": "공식 서버", + "B服:扫码后按提示完成授权": "Bilibili: 스캔 후 안내에 따라 연동 승인", + "使用森空岛 App 扫描二维码": "스카이랜드 앱으로 QR 코드 스캔", + "使用森空岛 App 扫码并确认,支持官服和 B 服。": "스카이랜드 앱으로 QR 코드를 스캔하고 확인하세요. 공식 및 Bilibili 서버를 지원합니다.", + "重新获取二维码": "QR 코드 재발급", + "二维码每 10 秒自动刷新,也可手动刷新。": "QR 코드는 10초마다 자동으로 갱신됩니다. 직접 갱신할 수도 있습니다.", + "已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "스캔 완료, 확인을 기다리고 있습니다. QR 코드는 1분 후 자동으로 갱신됩니다.", + "获取二维码": "QR 코드 발급", + "使用鹰角账号登录官服。B服请选择森空岛扫码。": "공식 서버는 Hypergryph 계정으로 로그인하세요. Bilibili 서버는 스카이랜드 QR 로그인을 선택하세요.", + "手机号": "휴대폰 번호", + "{n} 秒后重发": "{n}초 후 재전송", + "发送验证码": "인증 코드 전송", + "登录并绑定": "로그인 및 연동", + "恢复账号对局": "계정 대전 이어하기", + "登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "같은 계정으로 로그인하고 같은 서버의 캐릭터를 선택하면 다른 기기에서 만료되지 않은 대전을 이어할 수 있습니다. 서버가 재시작되면 복구할 수 없습니다.", + "正在查询账号对局…": "계정 대전 확인 중…", + "此对局已结束或过期,请刷新后重试。": "대전이 종료되었거나 만료되었습니다. 새로고침한 후 다시 시도하세요.", + "当前角色没有可恢复的对局。": "현재 캐릭터로 이어할 수 있는 대전이 없습니다.", + "对局进行中": "대전 진행 중", + "同盟等候中": "동맹 대기 중", + "如果此角色仍在其他设备在线,恢复后将断开旧连接。": "이 캐릭터가 다른 기기에 접속 중이라면 이어하기 후 기존 연결이 끊어집니다." } diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index bc763978..dff25fe2 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -1222,5 +1222,58 @@ "{names}只能部署在召唤者相邻四格,已退回整备区": "{names}只能部署在召喚者相鄰四格,已退回整備區", "{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}只能部署在召喚者相鄰四格,整備區已滿,下回合返還", "装备库存不足,未获得该装备": "裝備庫存不足,未獲得該裝備", - "库存不足": "庫存不足" + "库存不足": "庫存不足", + "需要人机验证,请点击下方按钮完成验证。": "需要人機驗證,請點擊下方按鈕完成驗證。", + "开始人机验证": "開始人機驗證", + "正在加载验证组件…": "正在載入驗證元件…", + "验证已取消,请点击按钮重试。": "驗證已取消,請點擊按鈕重試。", + "验证通过,正在继续…": "驗證通過,正在繼續…", + "暂不支持此验证类型,请使用验证码或扫码登录。": "暫不支援此驗證類型,請使用驗證碼或掃碼登入。", + "验证组件加载失败,请重试。": "驗證元件載入失敗,請重試。", + "验证未完成,请重试。": "驗證未完成,請重試。", + "官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "官服可使用手機號登入;B服請使用森空島掃碼,並按頁面提示完成授權。", + "此服务器未启用账号绑定,请联系服主开启后再进入。": "此伺服器未啟用帳號綁定,請聯絡服主開啟後再進入。", + "游戏会话已失效,请关闭后重试。": "遊戲工作階段已失效,請關閉後重試。", + "连接失败,请重试。": "連線失敗,請重試。", + "二维码加载失败,请重试。": "二維碼載入失敗,請重試。", + "服务器响应异常,请重试。": "伺服器回應異常,請重試。", + "已绑定:{name}": "已綁定:{name}", + "绑定明日方舟账号": "綁定明日方舟帳號", + "登录成功,未找到明日方舟角色。": "登入成功,未找到明日方舟角色。", + "登录失败,请重试。": "登入失敗,請重試。", + "验证码已发送,请查看短信。": "驗證碼已傳送,請查看簡訊。", + "二维码已失效,请重新获取。": "二維碼已失效,請重新取得。", + "请在验证窗口中完成人机验证。": "請在驗證視窗中完成人機驗證。", + "绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "綁定後自動取得角色暱稱作為博士代號,取得成功後即可開始。", + "绑定账号后自动获取": "綁定帳號後自動取得", + "绑定账号": "綁定帳號", + "· 绑定账号并开始后将自动加入": "· 綁定帳號並開始後將自動加入", + "选择明日方舟角色": "選擇明日方舟角色", + "刷新角色": "重新整理角色", + "解绑账号": "解除帳號綁定", + "登录方式": "登入方式", + "森空岛扫码": "森空島掃碼", + "验证码": "驗證碼", + "密码": "密碼", + "官服": "官服", + "B服:扫码后按提示完成授权": "B服:掃碼後按提示完成授權", + "使用森空岛 App 扫描二维码": "使用森空島 App 掃描二維碼", + "使用森空岛 App 扫码并确认,支持官服和 B 服。": "使用森空島 App 掃碼並確認,支援官服和 B 服。", + "重新获取二维码": "重新取得二維碼", + "二维码每 10 秒自动刷新,也可手动刷新。": "二維碼每 10 秒自動更新,也可手動更新。", + "已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "已掃碼,等待確認;二維碼將在 1 分鐘後自動更新。", + "获取二维码": "取得二維碼", + "使用鹰角账号登录官服。B服请选择森空岛扫码。": "使用鷹角帳號登入官服。B服請選擇森空島掃碼。", + "手机号": "手機號碼", + "{n} 秒后重发": "{n} 秒後重傳", + "发送验证码": "傳送驗證碼", + "登录并绑定": "登入並綁定", + "恢复账号对局": "恢復帳號對局", + "登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "登入同一帳號並選擇同一區服角色,可在其他裝置繼續尚未過期的對局。伺服器重新啟動後無法恢復。", + "正在查询账号对局…": "正在查詢帳號對局…", + "此对局已结束或过期,请刷新后重试。": "此對局已結束或過期,請重新整理後重試。", + "当前角色没有可恢复的对局。": "目前角色沒有可恢復的對局。", + "对局进行中": "對局進行中", + "同盟等候中": "同盟等候中", + "如果此角色仍在其他设备在线,恢复后将断开旧连接。": "如果此角色仍在其他裝置上線,恢復後將中斷舊連線。" } diff --git a/public/js/main.js b/public/js/main.js index 52782875..3e348499 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -8,13 +8,13 @@ // otherwise → Lobby // Deep link `?room=CODE`: remembered at boot, auto-joined once the player has entered and the // session is online (after a short grace period in case the server restores a room on resume). -// Reloading a tab that already passed the title re-enters automatically (sessionStorage flag) and +// Reloading a tab that already passed the title re-enters after checking its current role binding and // resumes the server session with the saved token; stale room/match state is dropped if the // server does not re-push it within RESTORE_GRACE_MS after `welcome`. Boot waits for // `identity.init()` (which token this tab may use without stealing another live tab's session) // before the first connect; a page restored from the back/forward cache reloads. // A `welcome` with a NEW playerId (the server restarted / the session expired) while a room or match was on screen: -// toast 「服务器会话已重置,上一局模拟已结束」 (store.js sessionResetNotice) and return to the lobby with nothing stale. +// drop stale match state (store.js sessionResetNotice); an unauthenticated transport returns to the title for checked entry. // // Shared modules are imported relatively ('../../shared/…' resolves to /shared/… in the browser). // Multi-device support (ui/device.js + css/devices.css): feature classes on , no page zoom, safe areas, rotation @@ -41,6 +41,7 @@ import { store, useStore, emptyMatch, selectRoute, sessionResetNotice, isSpectat import { data } from './data.js'; import { GAME_FILES } from './ui/gameComponents.js'; import { TitleScreen, sanitizeName } from './screens/title.js'; +import { acceptAccountWelcome, restoreBoundEntry } from './skland/gate.js'; import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js'; import { RoomScreen } from './screens/room.js'; import { GameScreen } from './screens/game.js'; @@ -145,6 +146,7 @@ function backToLobby() { } function onWelcome(msg) { + const accountAccepted = acceptAccountWelcome(msg); identity.saveToken(msg.token); identity.rememberMatch(null); const prev = store.get(); @@ -156,8 +158,8 @@ function onWelcome(msg) { if (prevId != null && prevId !== msg.playerId) { // A brand-new server session (the server restarted — crashed / killed, so no room.closed arrived — or this session - // expired on it): whatever we showed before is gone — back to the lobby cleanly and say why. - const notice = sessionResetNotice(prev, msg.playerId); + // expired on it): clear the old snapshot. A login-only transport stays at the title until checked account entry. + const notice = accountAccepted ? sessionResetNotice(prev, msg.playerId) : null; backToLobby(); if (notice) toast(t(notice), 'warn', { ttl: 7000 }); } else if (prev.room || prev.match.public) { @@ -303,7 +305,7 @@ function App() { return html`
${error ? html`<${ScreenCrashed} error=${error} reset=${resetError} />` : html`<${Screen} key=${route} />`} - <${ConnectionBanner} /> + <${ConnectionBanner} continueSession=${() => restoreBoundEntry(sanitizeName)} /> <${ToastHost} /> <${UiHosts} /> <${GuideHost} /> @@ -360,7 +362,7 @@ async function boot() { const entered = identity.wasEntered() && !!savedName; store.set((s) => ({ me: { ...s.me, name: savedName }, - session: { entered }, + session: { entered: false }, ui: { ...s.ui, pendingJoin }, })); @@ -378,9 +380,10 @@ async function boot() { // Optional local-client art manifest (emotes, tutorial pages, official UI sprites; DESIGN §13). data.load('local').catch(() => {}); - const connectWhenReady = identityReady.then(() => { - if (entered) net.setName(savedName); - else net.connect(); + const connectWhenReady = identityReady.then((token) => { + if (entered && token) return restoreBoundEntry(sanitizeName, { automatic: true }); + identity.setEntered(false); + net.connect(); }); // the language (and its UI translations) before the first render: no Chinese flash for an English player const langReady = initLang().catch((err) => console.warn('[app] language setup failed', err)); diff --git a/public/js/net.js b/public/js/net.js index 44510f02..05705985 100644 --- a/public/js/net.js +++ b/public/js/net.js @@ -746,6 +746,7 @@ export function createIdentity(deps = {}) { let initPromise = null; let initialized = false; let resumeBusy = false; + let explicitClaim = false; // account entry POST supplied the token; do not persist until welcome let current = null; // token this tab uses (null = let the server create a session) let resolving = null; // { hashes: Set, taken: Set } while init() waits for claims let owned = sget(session, K_TOKEN); @@ -811,6 +812,7 @@ export function createIdentity(deps = {}) { // The original page can reappear after resume's query but before its hello. Keep our old // session in storage and give back the tentative selection immediately in that interval. if (current && tokenHash(current) === hash) { + explicitClaim = false; if (current === owned) { owned = null; welcomeAt = null; welcomed = false; sdel(session, K_WELCOME); @@ -911,6 +913,13 @@ export function createIdentity(deps = {}) { } catch { return false; } finally { resumeBusy = false; } }, + /** Select an authenticated account entry token without treating an unacknowledged hello as a saved session. */ + stageToken(token) { + if (!isToken(token)) return false; + current = token; + explicitClaim = true; + return true; + }, /** @returns {string} remembered player name ('' if none) */ loadName: () => (sget(local, K_NAME) || '').slice(0, 64), /** @param {string} name */ @@ -924,6 +933,7 @@ export function createIdentity(deps = {}) { }, /** A shared candidate must never replace a connected session, including a hello already in flight. */ getTokenClaim() { + if (explicitClaim) return { noReplace: false }; return current === owned && owned ? { noReplace: false, ...(welcomeAt != null ? { claimAt: welcomeAt } : {}) } : { noReplace: true }; @@ -935,6 +945,7 @@ export function createIdentity(deps = {}) { /** @param {string} token from `welcome` */ saveToken(token) { if (!isToken(token)) return; + explicitClaim = false; if (current !== token) rememberMatch(null); if (owned !== token || welcomeAt == null) welcomeAt = now(); owned = token; @@ -946,6 +957,7 @@ export function createIdentity(deps = {}) { }, /** Forget this tab's token (e.g. the server said the session is invalid). */ clearToken() { + explicitClaim = false; rememberMatch(null); const t = current || sget(session, K_TOKEN); current = null; diff --git a/public/js/screens/lobby.js b/public/js/screens/lobby.js index 71777520..bce5ce77 100644 --- a/public/js/screens/lobby.js +++ b/public/js/screens/lobby.js @@ -11,7 +11,6 @@ // Texts go through t() (docs/I18N.md); the module-level tables hold msgids (N_) translated where they are shown, the // config.json mode texts come localized from data.js. -import { ResumeMatchButton } from '../ui/resumeMatch.js'; import { useEffect, useRef, useState } from '../../vendor/hooks.module.js'; import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor, ERR } from '../../../shared/constants.js'; import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js'; @@ -321,7 +320,6 @@ export function LobbyScreen() {

${t('选择模拟协议')}

- <${ResumeMatchButton} /> <${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} /> + +
+ + + +

+ +
+

扫码登录 · Scan to log in

+

用森空岛 App 扫描二维码,或在 App 内打开深链;面板每 2 秒问一次状态 · + scan the code with the Skland app; the panel polls every 2 s.

+
+
+

+

+
+ + + + + +

返回首页 · Back to home

+ + + + + diff --git a/server/http/media.js b/server/http/media.js index b05de0a5..306e78f5 100644 --- a/server/http/media.js +++ b/server/http/media.js @@ -19,9 +19,10 @@ import { hashedFileOpts } from './assetIndex.js'; * `/media/~/bgm/act1` (a served manifest's `/assets/~/audio/bgm/act1.mp3`, public/js/media.js): static.js * strips the segment and hands it over as `urlHash`; the answer is `immutable` when it names the resolved file's bytes * (assetIndex.js), else `no-cache`, and the ETag is the file's content hash when the index has it. - * @param {{ urlHash?: string | null, assetIndex?: ReturnType | null }} [hashing] + * @param {{ urlHash?: string | null, assetIndex?: ReturnType | null, + * isPrivate?: (file: string) => Promise }} [hashing] */ -export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log, { urlHash = null, assetIndex = null } = {}) { +export async function serveMedia(req, res, rest, query, publicDir, gzipCache, log, { urlHash = null, assetIndex = null, isPrivate = async () => false } = {}) { const root = path.join(path.resolve(publicDir), 'assets', 'audio'); const segments = String(rest || '').split('/').filter((s) => s.length > 0); if (!segments.length || rest.endsWith('/')) { sendError(req, res, 404, '页面不存在 · Not found'); return; } @@ -48,6 +49,7 @@ export async function serveMedia(req, res, rest, query, publicDir, gzipCache, lo stat = await fsp.stat(absPath); } catch { continue; } if (!stat.isFile()) continue; + if (await isPrivate(absPath)) { sendError(req, res, 404, '页面不存在 · Not found'); return; } // serveFile decides Content-Type from the resolved name (`.mp3` → audio/mpeg) — Range/ETag handling is shared. // Cache policy is that of the public path the client would otherwise have asked for (`/assets/audio/…`, 1 day), // or that of its content-hashed form. diff --git a/server/http/routes.js b/server/http/routes.js index fe3f505d..10907d40 100644 --- a/server/http/routes.js +++ b/server/http/routes.js @@ -2,6 +2,8 @@ // (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md) // // * a URL longer than 4096 characters → 414; one that does not parse → 400; +// * /skland/* → the Skland module's own listener when it is enabled (server/skland/http.js; SP_SKLAND=1). It owns +// its methods (POST), so it runs before the GET/HEAD gate below; // * any method but GET / HEAD → 405 with `Allow: GET, HEAD`; // * GET /healthz → JSON status (protocol `version`, release `app`, uptime, the served `build`, sockets, sessions, // rooms, matches), never cached; `?traffic=1` adds the traffic counters (traffic.js: bytes by category since the @@ -48,16 +50,23 @@ const wantsTraffic = (query) => /(?:^|&)traffic(?:=(?:1|true|yes))?(?:&|$)/i.tes * The request listener for `http.createServer`. * @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, * rawPath: string, query: string) => Promise, - * health: Parameters[0], log: object, trustProxy?: 'auto' | boolean }} deps + * health: Parameters[0], log: object, trustProxy?: 'auto' | boolean, + * skland?: ((req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, + * parts: { rawPath: string, query: string }) => Promise) | null }} deps * trustProxy: whose forwarding headers name the client (net.js clientAddress; the server's TRUST_PROXY) + * skland: the /skland/* handler when the module is enabled (server/skland/http.js; SP_SKLAND=1). It owns its paths + * and its methods (POST), so it runs before the GET/HEAD method gate. * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void} */ -export function createRequestHandler({ serveStatic, health, log, trustProxy = 'auto' }) { +export function createRequestHandler({ serveStatic, health, log, trustProxy = 'auto', skland = null }) { async function handleRequest(req, res) { const url = req.url || '/'; if (url.length > MAX_URL_LENGTH) { sendError(req, res, 414, '请求地址过长 · URI too long'); return; } const parts = splitUrl(url); if (!parts) { sendError(req, res, 400, '请求地址无效 · Bad request'); return; } + if (skland && (parts.rawPath === '/skland' || parts.rawPath.startsWith('/skland/'))) { + if (await skland(req, res, parts)) return; + } if (req.method !== 'GET' && req.method !== 'HEAD') { res.setHeader('Allow', 'GET, HEAD'); sendError(req, res, 405, '不支持的请求方法 · Method not allowed'); diff --git a/server/http/static.js b/server/http/static.js index 31b6de3d..9fbcd625 100644 --- a/server/http/static.js +++ b/server/http/static.js @@ -29,6 +29,7 @@ // Files go out through files.js serveFile. import fsp from 'node:fs/promises'; +import { realpathSync } from 'node:fs'; import path from 'node:path'; import { MEDIA_PREFIX } from '../../shared/media.js'; import { splitHashedPath } from '../../shared/assetPaths.js'; @@ -62,7 +63,7 @@ const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none', /** * Create the static request handler. * @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, packsDir?: string, - * packs?: ReturnType, log?: object, + * packs?: ReturnType, log?: object, privateFiles?: string[], * assetIndex?: ReturnType | null, * views?: ReturnType | null }} dirs * packs: the server's pack registry (default: one over publicDir, dataDir and packsDir — ROOT/packs); @@ -70,7 +71,14 @@ const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none', * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, rawPath: string, query: string) => Promise} */ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), packsDir = path.join(ROOT, 'packs'), packs = null, log = noopLog, - assetIndex = null, views = null }) { + assetIndex = null, views = null, privateFiles = [] }) { + const canonical = (file) => process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file); + const privatePaths = new Set(privateFiles.map(canonical)); + for (const file of privateFiles) { + try { privatePaths.add(canonical(realpathSync(file))); } catch { /* Side files may not exist yet. */ } + } + const isPrivate = async (file) => privatePaths.size > 0 && (privatePaths.has(canonical(file)) + || privatePaths.has(canonical(await fsp.realpath(file)))); const registry = packs || createPackRegistry({ publicDir, dataDir, packsDir }, { log: /** @type {any} */ (log) }); const mounts = [ { prefix: '/data/', name: 'data', dir: path.resolve(dataDir) }, @@ -107,13 +115,13 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa if (parts.length === 1 && parts[0] === PACK_INDEX_FILE) { sendJson(req, res, 200, registry.index()); return; } const abs = parts.length >= 2 && !parts.some((s) => s === '' || s === '.' || s === '..') ? registry.servable(parts[0], parts.slice(1).join('/')) : null; const st = abs ? await fsp.stat(abs).catch(() => null) : null; - if (!st || !st.isFile()) { sendError(req, res, 404, '页面不存在 · Not found', decoded.length <= 200 ? decoded : ''); return; } + if (!st || !st.isFile() || await isPrivate(abs)) { sendError(req, res, 404, '页面不存在 · Not found', decoded.length <= 200 ? decoded : ''); return; } await serveFile(req, res, abs, st, 'packs', parts, query, gzipCache, log); return; } // Extension-less audio (download-manager avoidance): /media/bgm/act1 → /assets/audio/bgm/act1.mp3 if (decoded.startsWith(MEDIA_PREFIX)) { - await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log, { urlHash, assetIndex }); + await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log, { urlHash, assetIndex, isPrivate }); return; } // The served manifests: every art URL with its content hash (assetViews.js), once the index is ready @@ -137,6 +145,7 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa return; } let absPath = path.join(mount.dir, ...segments); + if (privatePaths.has(canonical(absPath))) { sendError(req, res, 404, '页面不存在 · Not found'); return; } if (absPath !== mount.dir && !absPath.startsWith(mount.dir + path.sep)) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; } let stat; @@ -183,6 +192,7 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa } const indexed = mount.name === 'public' && segments.length > 1 && ASSET_INDEX_ROOTS.includes(segments[0]); const opts = indexed ? hashedFileOpts(assetIndex, segments.join('/'), stat, urlHash) : (urlHash ? { cacheControl: 'no-cache' } : {}); + if (await isPrivate(absPath)) { sendError(req, res, 404, '页面不存在 · Not found'); return; } await serveFile(req, res, absPath, stat, mount.name, segments, query, gzipCache, log, opts); }; } diff --git a/server/index.js b/server/index.js index 9d2f9943..f8bd8fe4 100644 --- a/server/index.js +++ b/server/index.js @@ -17,6 +17,9 @@ // http/routes.js the request listener: security headers, 414 / 400 / 405, GET /healthz → JSON status, else static // http/traffic.js the traffic counters: bytes sent per category (assets / audio / code / data) and on the // WebSocket, since the start (GET /healthz?traffic=1) +// skland/ the opt-in Skland account binding (SP_SKLAND=1): /skland/* — QR / 验证码 / 密码 login, one +// SQLite row per player. server/skland/http.js is handed to routes.js as its own listener (it +// owns POST); docs/skland.md // http/common.js what every answer shares: security headers, URL split, error page, JSON replies, bare 400 // http/boot.js a pending update package first (update.js: old files deleted, the install verified against // MANIFEST.json), banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT / @@ -29,6 +32,7 @@ // The server only auto-listens when this file is the process entry point. import http from 'node:http'; +import path from 'node:path'; import { getData, loadData } from './data.js'; import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy, assetIndexOptionsFrom } from './http/config.js'; import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js'; @@ -42,6 +46,13 @@ import { createAssetIndex } from './http/assetIndex.js'; import { createManifestViews } from './http/assetViews.js'; import { answerClientError } from './http/common.js'; import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js'; +import { createSklandStore } from './skland/store.js'; +import { createSklandService } from './skland/service.js'; +import { createSklandHttp, createSklandOffHandler } from './skland/http.js'; +import { createSessionPlayerResolver } from './skland/session.js'; +import { createAccountAuth } from './skland/auth.js'; +import { createAccountHttp } from './skland/accountHttp.js'; +import { createAccountRecovery } from './skland/recovery.js'; // The public API of this module (tests and tools import it from here); the code lives in ./http/. export { @@ -60,8 +71,12 @@ export { * maxConnectionsPerAddr?: number, maxRoomsPerAddr?: number, maxMatchesPerAddr?: number, resyncMinGapMs?: number, * heavyPerSec?: number, heavyBurst?: number, trustProxy?: 'auto' | boolean, soloReconnectWindowMs?: number, * assetIndex?: boolean | { cacheFile?: string | null, delayMs?: number }, + * skland?: boolean | { storeFile?: string, fetchImpl?: typeof fetch, client?: object, scanTimeoutMs?: number, + * authSecret?: string, publicOrigin?: string, diagnosticKey?: string, authNow?: () => number }, * }} [opts] * assetIndex: the content-hash index (default on; false or SP_ASSET_INDEX=0: off — config.js assetIndexOptionsFrom) + * skland: the Skland account binding (server/skland/), off by default (SP_SKLAND=1 turns it on). `storeFile` picks + * the SQLite file (SP_SKLAND_DB, default data/skland.db); `fetchImpl` replaces the upstream HTTP (tests). * @returns {Promise<{ port: number, host: string, url: string, server: http.Server, wss: import('ws').WebSocketServer, * lobby: import('./lobby.js').Lobby, network: import('./net.js').Network, * registry: import('./net.js').SessionRegistry, packs: ReturnType, @@ -83,14 +98,20 @@ export async function startServer(opts = {}) { const indexOptions = assetIndexOptionsFrom(opts, publicDir); const assetIndex = indexOptions ? createAssetIndex({ publicDir, log, ...indexOptions }) : null; const views = assetIndex ? createManifestViews({ dataDir, index: assetIndex, log }) : null; - const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log, assetIndex, views }); + // The Skland account binding (server/skland/): off unless asked for (SP_SKLAND=1, or opts.skland). Its own SQLite + // file (SP_SKLAND_DB, default /skland.db) holds one row per player; the routes are /skland/* (POST). + const skland = createSklandModule(opts, { log, registry, network, lobby }); + const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log, assetIndex, views, + privateFiles: skland?.privateFiles || [] }); + // Off still owns /skland/*: one uniform JSON 404 beats a static 404 for GET and a 405 for POST (skland/http.js). + const sklandHttp = skland ? skland.http : createSklandOffHandler(); const startedAt = Date.now(); // The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz. resetBuildTag(); buildTag(); const traffic = createTrafficCounter(); - const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex, views }, log, trustProxy: network.opts.trustProxy })); + const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex, views }, log, trustProxy: network.opts.trustProxy, skland: sklandHttp })); server.on('clientError', answerClientError); const wss = attachWebSocket(server, { network, log, traffic }); @@ -142,6 +163,7 @@ export async function startServer(opts = {}) { try { lobby.shutdown('shutdown'); } catch (e) { log.error('[shutdown] lobby', e); } network.close(); assetIndex?.close(); + try { skland?.close(); } catch (e) { log.error('[shutdown] skland', e); } await new Promise((resolve) => { server.close(() => resolve()); server.closeIdleConnections?.(); @@ -152,7 +174,37 @@ export async function startServer(opts = {}) { return closing; } - return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, assetIndex, views, close }; + return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, assetIndex, views, skland: skland ? skland.service : null, close }; +} + +/** + * The Skland module (server/skland/), or null when it is off. Off is the default: an unconfigured server keeps no + * account credentials at all. Turn it on with `SP_SKLAND=1` (or `opts.skland`), which every caller of startServer can + * pass — the HTTP tests inject their own store file and a stub `fetchImpl`, so nothing here reaches the network. + * @param {Record} opts + * @param {{ log: object, registry: import('./net.js').SessionRegistry, network: import('./net.js').Network }} deps + * @returns {{ service: any, http: any, privateFiles: string[], close: () => void } | null} + */ +function createSklandModule(opts, { log, registry, network, lobby }) { + const cfg = opts.skland; + const enabled = !!cfg || process.env.SP_SKLAND === '1'; + if (!enabled) return null; + const conf = typeof cfg === 'object' ? cfg : {}; + const file = conf.storeFile || process.env.SP_SKLAND_DB || path.join(ROOT, 'data', 'skland.db'); + const store = createSklandStore({ file, log }); + const service = createSklandService({ store, fetchImpl: conf.fetchImpl, client: conf.client, scanTimeoutMs: conf.scanTimeoutMs }); + const auth = createAccountAuth({ file: store.file, service, registry, secret: conf.authSecret || process.env.SP_AUTH_SECRET, + publicOrigin: conf.publicOrigin || process.env.SP_PUBLIC_ORIGIN, diagnosticKey: conf.diagnosticKey || process.env.SP_SKLAND_DIAGNOSTIC_KEY, + trustProxy: network.opts.trustProxy, now: conf.authNow, onExpire: (session) => lobby.onExpire(session) }); + network.accountPolicy = auth.gamePolicy; + log.info?.(`[skland] account binding on — ${store.file}`); + return { + service, + http: createAccountHttp({ service, auth, registry, recovery: createAccountRecovery({ registry, lobby }), + diagnostic: createSklandHttp({ service, sessionPlayer: createSessionPlayerResolver(registry) }) }), + privateFiles: store.file === ':memory:' ? [] : [store.file, store.file + '-wal', store.file + '-shm'], + close: () => { auth.close(); store.close(); }, + }; } // `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js). diff --git a/server/net.js b/server/net.js index 38514e2c..cafdadf2 100644 --- a/server/net.js +++ b/server/net.js @@ -184,6 +184,16 @@ export class SessionRegistry { if (this.byTokenMap.get(s.token) === s) this.byTokenMap.delete(s.token); } + /** Rotate an authenticated session's bearer after attaching a new socket. @param {Session} s */ + rotateToken(s) { + const previous = s.token; + if (this.byTokenMap.get(previous) === s) this.byTokenMap.delete(previous); + let token; + do token = newToken(); while (token === previous || this.byTokenMap.has(token)); + s.token = token; + this.byTokenMap.set(token, s); + } + /** * The reconnect window of one session: the registry's, or the session's own `resumeWindowMs` when that is longer * (the lobby may only extend it, never shorten it). @@ -526,6 +536,8 @@ export class Network { /** @type {Map} open sockets per client network key */ this.connsPerKey = new Map(); this.closed = false; + // Optional fork account policy; transport and the upstream game protocol remain usable when it is absent. + this.accountPolicy = null; this.heartbeatTimer = setInterval(() => this.heartbeat(), this.opts.heartbeatMs); this.heartbeatTimer.unref?.(); const sweepMs = Math.max(20, Math.min(15_000, Math.floor(this.opts.reconnectWindowMs / 4))); @@ -559,6 +571,7 @@ export class Network { if (this.closed) { try { ws.close(CLOSE.SHUTDOWN, 'server shutdown'); } catch { /* ignore */ } return; } const conn = new Connection(ws, clientAddress(req, this.opts.trustProxy), this.now(), this.opts); this.conns.set(ws, conn); + if (this.accountPolicy) conn.account = this.accountPolicy.connection(req); if (conn.key) this.connsPerKey.set(conn.key, (this.connsPerKey.get(conn.key) || 0) + 1); ws.on('message', (data, isBinary) => { try { this.onFrame(conn, data, isBinary); } catch (e) { this.log.error('[net] frame handler crashed', e); } @@ -612,6 +625,9 @@ export class Network { } if (msg.t === 'hello') { this.onHelloMsg(conn, msg, now); return; } if (!conn.session) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'hello required')); return; } + if (this.accountPolicy && !this.accountPolicy.authorized(conn)) { + this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'account binding required')); return; + } if (HEAVY_TYPES.has(msg.t) && !conn.heavy.take(now)) { this.reply(conn, errorMsg(ERR.RATE, rid, `${msg.t} too often`)); return; } let res; @@ -640,6 +656,11 @@ export class Network { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, `version mismatch: server ${PROTOCOL_VERSION}`)); return; } + if (this.accountPolicy) { + const account = this.accountPolicy.hello(conn, msg); + if (!account) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'account authentication required')); return; } + msg = { ...msg, ...account }; + } const name = sanitizeName(msg.name); if (!name) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'bad field name')); return; } @@ -670,6 +691,7 @@ export class Network { session.lastSeen = now; session.addr = conn.ip; session.limitKey = conn.key; + this.accountPolicy?.attached?.(conn, { repeat }); let extra = null; try { extra = this.handler.welcomeInfo?.() ?? null; } catch (e) { this.log.error('[net] welcomeInfo crashed', e); } diff --git a/server/skland/accountHttp.js b/server/skland/accountHttp.js new file mode 100644 index 00000000..962ee10f --- /dev/null +++ b/server/skland/accountHttp.js @@ -0,0 +1,108 @@ +// i18n-ignore-file: API errors are diagnostics; the account UI supplies translated labels. +import { sendJson } from '../http/common.js'; +import { readJsonBody } from './http.js'; +import { ACCOUNT_PREFIX } from './auth.js'; +import { SESSION_BINDING_PREFIX } from './session.js'; + +export function createAccountHttp({ service, auth, registry, recovery, diagnostic }) { + const endpoints = { status: 'GET', roles: 'GET', recovery: 'GET', enter: 'POST', resume: 'POST', 'scan/start': 'POST', 'scan/poll': 'POST', + 'phone/send': 'POST', 'phone/login': 'POST', 'password/login': 'POST', 'role/select': 'POST', unbind: 'POST' }; + return async (req, res, parts) => { + const fail = (status, error, code) => { sendJson(req, res, status, { ok: false, error, ...(code ? { code } : {}) }); return true; }; + if (!auth.originAllowed(req)) return fail(403, '跨站请求已拒绝 · Cross-origin request denied'); + if (auth.limited(req)) return fail(429, '请求过于频繁 · Too many requests'); + if (!parts.rawPath.startsWith('/skland/session/')) { + if (!auth.diagnosticAllowed(req)) return fail(403, '诊断接口需要管理员密钥 · Diagnostic API requires an admin key'); + return diagnostic(req, res, parts); + } + const endpoint = parts.rawPath.slice('/skland/session/'.length); + if (!Object.hasOwn(endpoints, endpoint)) return fail(404, '未知接口 · Unknown endpoint'); + if (req.method !== endpoints[endpoint]) return fail(405, '不支持的请求方法 · Method not allowed'); + const match = /^Bearer ([a-f0-9]{32})$/i.exec(req.headers.authorization || ''); + const session = match && registry.byToken(match[1]); + if (!session || registry.isExpired(session, registry.now())) return fail(401, '游戏会话已失效 · Game session expired'); + const query = new URLSearchParams(parts.query); + let body = {}; + if (req.method === 'POST') { + try { body = await readJsonBody(req); } catch { return fail(400, '请求体无效 · Bad request'); } + if (!body || typeof body !== 'object' || Array.isArray(body)) return fail(400, '请求体无效 · Bad request'); + } + // Reading a POST body yields: logout, expiry or a device takeover may invalidate either credential meanwhile. + if (registry.byToken(match[1]) !== session) return fail(401, '游戏会话已失效 · Game session expired'); + let login; + try { login = auth.authenticate(req); } catch { auth.setCookie(req, res, null); return fail(401, '登录已过期,请重新绑定 · Login expired'); } + if (session.playerId.startsWith('a_') && (!login || auth.state(login).gamePlayerId !== session.playerId)) { + return fail(403, '身份不匹配 · Identity mismatch'); + } + // Claimed identity fields never choose the target row; all ownership comes from authenticated credentials. + const pendingPlayer = SESSION_BINDING_PREFIX + session.playerId; + const issue = () => { + login = auth.publish(pendingPlayer); + if (login) auth.setCookie(req, res, login); + }; + if (!login && service.status(pendingPlayer).bound) issue(); // Migrate an existing binding owned by this transport. + const player = login ? ACCOUNT_PREFIX + login.subject : pendingPlayer; + const syncSelection = () => { + if (!login) return; + const updated = auth.ensureSelection(login); + if (updated !== login) { login = updated; auth.setCookie(req, res, login); } + }; + try { + let answer; + if (endpoint === 'status') { syncSelection(); answer = login ? auth.state(login) : service.status(player); } + else if (endpoint === 'recovery' || endpoint === 'enter' || endpoint === 'resume') { + if (!login) return fail(401, '请先绑定账号 · Account binding required'); + syncSelection(); + const playerId = auth.state(login).gamePlayerId; + if (!playerId) return fail(409, '请选择有效角色 · A playable role is required'); + const item = recovery.describe(playerId); + if (endpoint === 'recovery') answer = { ok: true, recovery: item }; + else { + if (endpoint === 'resume' && (!item || typeof body.id !== 'string' || body.id !== item.id)) { + return fail(409, '此对局已结束或过期,请刷新后重试 · Recovery is no longer available', 'resume_expired'); + } + const target = auth.gameSession(login); + answer = { ok: true, gamePlayerId: target.playerId, token: target.token, name: target.name }; + } + } else if (endpoint === 'roles') { + if (!login) return fail(401, '请先绑定账号 · Account binding required'); + answer = { ok: true, roles: await service.roles(player, query.get('refresh') === '1') }; + syncSelection(); + } else if (endpoint === 'role/select') { + if (!login) return fail(401, '请先绑定账号 · Account binding required'); + const selected = auth.select(login, body.roleKey); + if (!selected) return fail(403, '无法选择该角色 · Role not owned or currently in a room'); + login = selected; auth.setCookie(req, res, login); answer = { ok: true, ...auth.state(login) }; + } else if (endpoint === 'unbind') { + if (login) { + const role = auth.roleOf(login), current = role && registry.byId(auth.state(login).gamePlayerId); + if (current?.roomCode) return fail(409, '请先离开同盟 · Leave your room first'); + auth.revoke(login); + } + service.unbind(pendingPlayer); auth.setCookie(req, res, null); answer = { ok: true }; + } else { + if (login) return fail(409, '请先解绑当前账号 · Unbind the current account first'); + if (endpoint === 'scan/start') answer = { ok: true, ...await service.startScan(player) }; + else if (endpoint === 'scan/poll') { + answer = { ok: true, ...await service.pollScan(player) }; + if (answer.state === 'confirmed' && !answer.error) issue(); + } else if (endpoint === 'phone/send') { + if (!body.phone) return fail(400, '缺少手机号 · Missing phone'); + answer = await service.sendPhoneCode(player, String(body.phone), body.captcha); + } else if (endpoint === 'phone/login') { + if (!body.phone || !body.code) return fail(400, '缺少登录信息 · Missing login fields'); + answer = await service.loginByPhoneCode(player, String(body.phone), String(body.code)); + if (answer.ok) issue(); + } else if (endpoint === 'password/login') { + if (!body.phone || !body.password) return fail(400, '缺少登录信息 · Missing login fields'); + answer = await service.loginByPhonePassword(player, String(body.phone), String(body.password), body.captcha, body.captchaStep); + if (answer.ok) issue(); + } + } + sendJson(req, res, 200, answer); + } catch (error) { + sendJson(req, res, 200, { ok: false, ...(endpoint === 'scan/poll' ? { state: 'error' } : {}), error: error.message }); + } + return true; + }; +} diff --git a/server/skland/auth.js b/server/skland/auth.js new file mode 100644 index 00000000..9a87b53f --- /dev/null +++ b/server/skland/auth.js @@ -0,0 +1,187 @@ +// Persistent account authentication. Provider credentials stay in SQLite; the browser receives only a signed JWT. +import { DatabaseSync } from 'node:sqlite'; +import { createHmac, createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; +import { TokenBucket, isLocalIp, sanitizeName } from '../net.js'; +import { roleKey, hasRoleIdentity } from '../../shared/accountIdentity.js'; + +export const ACCOUNT_PREFIX = '@account/'; +export const ACCOUNT_COOKIE = 'sp_account'; +export const LOGIN_TTL = 30 * 24 * 60 * 60; +const ISSUER = 'stronghold-account', AUDIENCE = 'stronghold-game'; +const json64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); +const equal = (a, b) => a.length === b.length && timingSafeEqual(a, b); +// 128 digest bits in base64url keep `n:` within g.watch's existing 32-character field limit. +export const accountPlayerId = (role) => 'a_' + createHash('sha256').update(roleKey(role)).digest().subarray(0, 16).toString('base64url'); + +export function createAccountAuth({ file, service, registry, secret, publicOrigin, diagnosticKey, trustProxy = 'auto', now = Date.now, onExpire }) { + const db = new DatabaseSync(file); + db.exec(`PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS account_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS account_logins ( + id TEXT PRIMARY KEY, subject TEXT NOT NULL, role_key TEXT, expires INTEGER NOT NULL, revoked INTEGER NOT NULL DEFAULT 0 + );`); + if (secret && Buffer.byteLength(secret) < 32) throw new Error('SP_AUTH_SECRET must contain at least 32 bytes'); + db.prepare('INSERT OR IGNORE INTO account_meta(key,value) VALUES (?,?)').run('signing_key', randomBytes(48).toString('base64url')); + const key = Buffer.from(secret || db.prepare('SELECT value FROM account_meta WHERE key=?').get('signing_key').value); + const configuredOrigin = publicOrigin ? new URL(publicOrigin).origin : null; + const signature = (value) => createHmac('sha256', key).update(value).digest(); + const playable = (role) => hasRoleIdentity(role) && !!sanitizeName(role.nickName); + const roleOf = (login) => service.status(ACCOUNT_PREFIX + login.subject).roles.find((role) => playable(role) && roleKey(role) === login.role_key) || null; + const active = (login) => login && !login.revoked && login.expires > Math.floor(now() / 1000) + && service.status(ACCOUNT_PREFIX + login.subject).bound; + const buckets = new Map(); + function limited(req) { + const id = req.socket?.remoteAddress || '?'; + if (!buckets.has(id)) { + if (buckets.size >= 4096) buckets.delete(buckets.keys().next().value); + buckets.set(id, new TokenBucket(3, 120, now())); + } + return !buckets.get(id).take(now()); + } + function originAllowed(req) { + if (req.headers['sec-fetch-site'] === 'cross-site') return false; + if (!req.headers.origin) return true; // Non-browser clients still need both credentials. + const secure = req.socket?.encrypted || (trustProxy !== false && (trustProxy === true || isLocalIp(req.socket?.remoteAddress)) + && req.headers['x-forwarded-proto'] === 'https'); + return req.headers.origin === (configuredOrigin || `${secure ? 'https' : 'http'}://${req.headers.host}`); + } + function cookieValue(req) { + const entries = (req.headers.cookie || '').split(';').map((part) => part.trim()).filter((part) => part.startsWith(ACCOUNT_COOKIE + '=')); + if (entries.length > 1) throw new Error('invalid account cookie'); + return entries.length ? entries[0].slice(ACCOUNT_COOKIE.length + 1) : null; + } + function verify(token) { + try { + if (typeof token !== 'string' || token.length > 4096) return null; + const parts = token.split('.'); + if (parts.length !== 3 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return null; + const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString()); + if (header.alg !== 'HS256' || header.typ !== 'JWT' || Object.keys(header).length !== 2) return null; + if (!equal(signature(parts[0] + '.' + parts[1]), Buffer.from(parts[2], 'base64url'))) return null; + const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString()); + const at = Math.floor(now() / 1000); + if (claims.iss !== ISSUER || claims.aud !== AUDIENCE || !Number.isInteger(claims.iat) || claims.iat > at + || !Number.isInteger(claims.exp) || claims.exp <= at || claims.exp <= claims.iat || claims.exp - claims.iat > LOGIN_TTL + || typeof claims.sub !== 'string' || typeof claims.jti !== 'string') return null; + const login = db.prepare('SELECT * FROM account_logins WHERE id=?').get(claims.jti); + return active(login) && login.subject === claims.sub && login.expires === claims.exp && login.role_key === claims.role ? login : null; + } catch { return null; } + } + function authenticate(req) { + const token = cookieValue(req); + if (!token) return null; + const login = verify(token); + if (!login) throw new Error('invalid account cookie'); + return login; + } + function setCookie(req, res, login) { + const secure = req.socket?.encrypted || configuredOrigin?.startsWith('https:') || req.headers.origin?.startsWith('https:') + || (trustProxy !== false && (trustProxy === true || isLocalIp(req.socket?.remoteAddress)) && req.headers['x-forwarded-proto'] === 'https'); + let value = '', maxAge = 0; + if (login) { + const at = Math.floor(now() / 1000); + const head = json64({ alg: 'HS256', typ: 'JWT' }); + const body = json64({ iss: ISSUER, aud: AUDIENCE, sub: login.subject, jti: login.id, + iat: at, exp: login.expires, role: login.role_key }); + value = head + '.' + body + '.' + signature(head + '.' + body).toString('base64url'); + maxAge = Math.max(0, login.expires - at); + } + res.setHeader('Set-Cookie', `${ACCOUNT_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure ? '; Secure' : ''}`); + } + function publish(player) { + const row = service._store.get(player); + if (!row || row.status !== 'bound') return null; + const roles = service.status(player).roles.filter(playable); + const selected = roles.find((role) => role.isDefault) || roles[0]; + const login = { id: randomUUID(), subject: randomUUID(), role_key: selected ? roleKey(selected) : null, + expires: Math.floor(now() / 1000) + LOGIN_TTL, revoked: 0 }; + const { player_id: _player, created_at: _created, updated_at: _updated, ...credentials } = row; + service._store.update(ACCOUNT_PREFIX + login.subject, credentials); + db.prepare('INSERT INTO account_logins(id,subject,role_key,expires) VALUES (?,?,?,?)') + .run(login.id, login.subject, login.role_key, login.expires); + service.unbind(player); // A replay of the old transport token cannot issue a fresh login after logout. + return login; + } + function select(login, selectedKey) { + const role = service.status(ACCOUNT_PREFIX + login.subject).roles.find((role) => playable(role) && roleKey(role) === selectedKey); + if (!role) return null; + const old = roleOf(login), session = old && registry.byId(accountPlayerId(old)); + if (session?.roomCode && selectedKey !== login.role_key) return null; + db.prepare('UPDATE account_logins SET role_key=? WHERE id=?').run(selectedKey, login.id); + return { ...login, role_key: selectedKey }; + } + function ensureSelection(login) { + if (roleOf(login)) return login; + const roles = service.status(ACCOUNT_PREFIX + login.subject).roles.filter(playable); + const role = roles.find((item) => item.isDefault) || roles[0]; + const selectedKey = role ? roleKey(role) : null; + if (selectedKey === login.role_key) return login; + db.prepare('UPDATE account_logins SET role_key=? WHERE id=?').run(selectedKey, login.id); + return { ...login, role_key: selectedKey }; + } + function state(login) { + const status = service.status(ACCOUNT_PREFIX + login.subject), role = roleOf(login); + return { ...status, loginId: login.id, selectedRoleKey: login.role_key, + gamePlayerId: role ? accountPlayerId(role) : null }; + } + function roleSession(role) { + const id = accountPlayerId(role); + let session = registry.byId(id); + if (session && registry.isExpired(session, registry.now())) { + registry.remove(session); + onExpire?.(session); // Remove the old seat before reusing its stable account playerId. + session = null; + } + if (!session) { + session = registry.create(sanitizeName(role.nickName)); + if (!session) throw new Error('server full'); + registry.byPlayerId.delete(session.playerId); session.playerId = id; registry.byPlayerId.set(id, session); + } + return session; + } + function revoke(login) { + db.prepare('UPDATE account_logins SET revoked=1 WHERE subject=?').run(login.subject); + service.unbind(ACCOUNT_PREFIX + login.subject); + } + const gamePolicy = { + connection(req) { + let login = null; + try { login = authenticate(req); } catch { /* Expired cookies can still establish a login-only transport. */ } + return { login, allowed: originAllowed(req) }; + }, + hello(conn, message) { + if (!conn.account.allowed) return null; + const login = conn.account.login; + if (login) { + const current = db.prepare('SELECT * FROM account_logins WHERE id=?').get(login.id); + const role = active(current) && current.role_key === login.role_key && roleOf(current); + const target = conn.session || (message.token && registry.byToken(message.token)); + // Merely restoring the cookie or viewing the title must not take over another device. A current role token + // resumes normally; the authenticated enter/resume POST supplies it after an explicit user action. + if (role && target?.playerId === accountPlayerId(role)) return { ...message, name: sanitizeName(role.nickName), token: target.token }; + } + // Anonymous hellos may establish transport for login, but never resume an authenticated game identity. + if (conn.session?.playerId.startsWith('a_')) return null; + const target = message.token && registry.byToken(message.token); + return { ...message, token: target?.playerId.startsWith('a_') ? undefined : message.token }; + }, + attached(conn, { repeat }) { + if (!repeat && conn.session?.playerId.startsWith('a_')) registry.rotateToken(conn.session); + }, + authorized(conn) { + const captured = conn.account?.login; + if (!captured) return false; + const current = db.prepare('SELECT * FROM account_logins WHERE id=?').get(captured.id); + const role = active(current) && current.role_key === captured.role_key && roleOf(current); + return !!role && conn.session?.playerId === accountPlayerId(role); + }, + }; + return { authenticate, setCookie, publish, select, ensureSelection, state, roleOf, revoke, gamePolicy, originAllowed, limited, + gameSession(login) { const role = roleOf(login); return role ? roleSession(role) : null; }, + diagnosticAllowed(req) { + const supplied = req.headers['x-sp-diagnostic-key']; + return !!diagnosticKey && typeof supplied === 'string' && equal(Buffer.from(supplied), Buffer.from(diagnosticKey)); + }, + close() { db.close(); }, + }; +} diff --git a/server/skland/client.js b/server/skland/client.js new file mode 100644 index 00000000..339196eb --- /dev/null +++ b/server/skland/client.js @@ -0,0 +1,333 @@ +// server/skland/client.js — HG account bindings and the legacy Skland login/signed API client. Every call uses an +// (i18n-ignore-file: what this file holds is not UI text — the Chinese strings are the internal chain-step labels of +// errors and logs, or the message of an upstream failure the HTTP layer turns into `{ ok: false, error }`, exactly the +// "Error messages are not player text" case of docs/I18N.md; the panel owns the wording a player reads) +// injected `fetchImpl` (default: globalThis.fetch), so tests run the whole chain against a stub and nothing here +// touches the network unless the server does. Account bindings follow the official account SDK; the legacy Skland +// endpoints/signing also follow community documentation (3mua.cn and TNXG/skland-api). Signing lives in ./crypto.js. +// +// The existing QR/SMS paths use Skland; password lookup uses the official HG account binding service: +// 扫码 genScan → pollScan → tokenByScanCode → (token + deviceToken) → grant → cred +// 验证码 sendPhoneCode → tokenByPhoneCode → (token + hgId + deviceToken) → grant → cred +// 密码 tokenByPhonePassword → token → binding-service grant (type 1) → Arknights binding_list +// +// A deviceToken is optional in the official account SDK's grant request. Password login must attempt the grant +// even on a fresh local player; only upstream status 109 (NEED_DEVICE_VERIFY) requires extra verification. +// Source: https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js (checked 2026-10-14). +// +// SMS/password/account grants may ask for Geetest (status 1). The browser displays the interactive widget and +// returns its proof to the challenged step; the server never solves the challenge or exposes account tokens. +// +// Cred refresh: code 10000 → cred_token expired → POST zonai /auth/refresh, retry once; code 10002 → cred expired → +// re-run grant → cred from the stored access_token (+ deviceToken), retry once; otherwise the player re-binds. + +import { SKLAND_APP_CODE, SKLAND_UA, signedHeaders, deviceHeaders } from './crypto.js'; + +const AS_BASE = 'https://as.hypergryph.com'; +const ZONAI_BASE = 'https://zonai.skland.com'; +const BINDING_BASE = 'https://binding-api-account-prod.hypergryph.com'; +// Official account SDK: Account.API.grantApp('binding'), app registry entry (checked 2026-10-14). +const BINDING_APP_CODE = 'be36d44aa36bfb5b'; +const POLL_INTERVAL_MS = 2000; +const POLL_TIMEOUT_MS = 120_000; + +/** Error carrying the upstream's message; `code` is the upstream status/code field, `step` the chain step. */ +export class SklandError extends Error { + /** + * @param {string} message @param {number | string} [code] @param {string} [step] + */ + constructor(message, code, step) { + super(message); + this.name = 'SklandError'; + this.code = code; + this.step = step; + } +} + +/** The upstream requested an interactive Geetest challenge; the browser completes it and retries that step. */ +export class SklandCaptchaError extends SklandError { + /** + * @param {{ gt?: string, challenge?: string, new_captcha?: boolean, geetestId?: string, riskType?: string }} captcha + */ + constructor(captcha, step = 'sendPhoneCode') { + super('需要人机验证,请完成验证后重试', 'captcha', step); + this.name = 'SklandCaptchaError'; + this.captcha = captcha; + } +} + +/** The upstream requested device verification (109); absence of a local deviceToken alone is not an error. */ +export class SklandNeedDeviceError extends SklandError { + constructor(message = '当前设备需要额外验证', step = 'getGrantCode') { + super(`${message},请使用验证码或扫码完成登录`, 109, step); + this.name = 'SklandNeedDeviceError'; + } +} + +async function readJson(res, step) { + try { return await res.json(); } catch { + throw new SklandError(`${step}:响应不是 JSON (HTTP ${res.status})`, res.status, step); + } +} + +/** as.hypergryph.com answers { status: 0, msg?, data? }; returns `data`. */ +function assertAs(json, step, captchaStep = step) { + if (json.status === 1 && json.data?.captcha) throw new SklandCaptchaError(json.data.captcha, captchaStep); + if (json.status === 109) throw new SklandNeedDeviceError(json.msg || undefined, step); + if (json.status !== 0) throw new SklandError(json.msg || `${step}失败`, json.status, step); + return json.data || {}; +} + +/** zonai.skland.com answers { code: 0, message?, data }; returns `data`. */ +function assertZonai(json, step) { + if (json.code !== 0) throw new SklandError(json.message || json.msg || `${step}失败`, json.code, step); + return json.data || {}; +} + +/** + * @param {{ + * fetchImpl?: typeof fetch, + * baseHeaders?: Record, + * pollIntervalMs?: number, + * pollTimeoutMs?: number, + * sleep?: (ms: number) => Promise, + * }} [opts] + */ +export function createSklandClient(opts = {}) { + const fetchImpl = opts.fetchImpl || globalThis.fetch; + const pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL_MS; + const pollTimeoutMs = opts.pollTimeoutMs ?? POLL_TIMEOUT_MS; + const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))); + const baseHeaders = { 'User-Agent': SKLAND_UA, Accept: 'application/json', ...(opts.baseHeaders || {}) }; + + async function post(url, body, extraHeaders) { + return fetchImpl(url, { + method: 'POST', + headers: { ...baseHeaders, 'Content-Type': 'application/json', ...(extraHeaders || {}) }, + body: JSON.stringify(body ?? {}), + }); + } + + // ── 扫码登录 ────────────────────────────────────────────────────────────────────────────── + + /** + * Step 1: request a QR scan session. The QR code must encode `scanUrl` (a `hypergryph://scan_login?…` deep link), + * not the scanId — the 森空岛 app's scanner reads the deep link. + * @returns {Promise<{ scanId: string, scanUrl: string }>} + */ + async function genScan() { + const res = await post(`${AS_BASE}/general/v1/gen_scan/login`, { appCode: SKLAND_APP_CODE }); + const json = await readJson(res, '获取扫码会话'); + const data = assertAs(json, '获取扫码会话'); + return { scanId: data.scanId, scanUrl: data.scanUrl || `hypergryph://scan_login?scanId=${data.scanId}` }; + } + + /** + * Step 2: one status check (the HTTP route polls per request; `pollScan` below loops it). + * The official account-center login keeps polling for 100 (unscanned) and 101 (awaiting phone confirmation). + * Source: https://web.hycdn.cn/user-center/index.fa9aa713.js (general/v1/scan_status, checked 2026-10-14). + * Only status 0 + data.scanCode completes confirmation; 102 and other failures remain errors. + * @returns {Promise<{ state: 'waiting' | 'confirmed', scanCode?: string, awaitingConfirmation?: boolean }>} + */ + async function checkScan(scanId) { + const res = await fetchImpl(`${AS_BASE}/general/v1/scan_status?scanId=${encodeURIComponent(scanId)}`, { headers: baseHeaders }); + const json = await readJson(res, '查询扫码状态'); + if (json.data && json.data.scanCode) return { state: 'confirmed', scanCode: json.data.scanCode }; + if (json.status === 100 || json.status === 101) return { state: 'waiting', awaitingConfirmation: json.status === 101 }; + throw new SklandError(json.msg || '查询扫码状态失败', json.status, 'checkScan'); + } + + /** + * Step 2 (looping): poll until the phone confirms, or the timeout passes. + * @param {string} scanId + * @param {{ onWait?: (state: string) => void }} [o] + * @returns {Promise} the scanCode + */ + async function pollScan(scanId, o = {}) { + const deadline = Date.now() + pollTimeoutMs; + for (;;) { + const { state, scanCode } = await checkScan(scanId); + if (state === 'confirmed') return scanCode; + if (Date.now() > deadline) throw new SklandError('扫码超时,请重试', 'timeout', 'pollScan'); + o.onWait?.(state); + await sleep(pollIntervalMs); + } + } + + /** Step 3: scanCode → { token, hgId?, deviceToken? }. */ + async function tokenByScanCode(scanCode) { + const res = await post(`${AS_BASE}/user/auth/v1/token_by_scan_code`, + { appCode: SKLAND_APP_CODE, from: 0, scanCode }, deviceHeaders()); + const json = await readJson(res, '扫码换取 token'); + const data = assertAs(json, '扫码换取 token'); + return { token: data.token, hgId: data.hgId || null, deviceToken: data.deviceToken || null }; + } + + // ── 验证码登录 ──────────────────────────────────────────────────────────────────────────── + + /** + * Send a login/register SMS (type 2, LOGIN_OR_REGISTER), matching the official account center: + * https://web.hycdn.cn/user-center/index.fa9aa713.js (checked 2026-10-14). + * Rate limited (status 102) and 人机验证 (status 1 + geetest) surface as errors; + * retry the latter with the solved `captcha` fields. + * @param {string} phone + * @param {Record} [captcha] Geetest v3/v4 proof supplied by the browser + * @returns {Promise<{ ok: true }>} + */ + async function sendPhoneCode(phone, captcha) { + const body = { phone, type: 2 }; + if (captcha) body.captcha = captcha; + const res = await post(`${AS_BASE}/general/v1/send_phone_code`, body); + const json = await readJson(res, '发送验证码'); + if (json.status === 1 && json.data && json.data.captcha) throw new SklandCaptchaError(json.data.captcha); + if (json.status === 102) throw new SklandError(json.msg || '发送频率太高,请稍后再试', 102, 'sendPhoneCode'); + assertAs(json, '发送验证码'); + return { ok: true }; + } + + /** 验证码 → { token, hgId, deviceToken } (this endpoint always returns the deviceToken). */ + async function tokenByPhoneCode(phone, code) { + const res = await post(`${AS_BASE}/user/auth/v2/token_by_phone_code`, + { appCode: SKLAND_APP_CODE, code, phone }, deviceHeaders()); + const json = await readJson(res, '验证码换取 token'); + const data = assertAs(json, '验证码换取 token'); + return { token: data.token, hgId: data.hgId || null, deviceToken: data.deviceToken || null }; + } + + // ── 密码登录 ────────────────────────────────────────────────────────────────────────────── + + /** 密码 → { token }; a deviceToken is not required locally to try the following grant. */ + async function tokenByPhonePassword(phone, password, captcha) { + const body = { phone, password }; + if (captcha) body.captcha = captcha; + const res = await post(`${AS_BASE}/user/auth/v1/token_by_phone_password`, body, deviceHeaders()); + const json = await readJson(res, '密码换取 token'); + const data = assertAs(json, '密码换取 token', 'password'); + if (!data.token) throw new SklandError('密码登录未返回 token', 'no_token', 'tokenByPhonePassword'); + return { token: data.token }; + } + + /** + * Query Arknights account bindings without a Skland account or cred. + * Official SDK: Account.API.grantApp('binding') → Role.API.getServerRoleList('ak'). + * Source: https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js (2026-10-14). + */ + async function getAccountBinding(accessToken, captcha) { + const body = { token: accessToken, appCode: BINDING_APP_CODE, type: 1 }; + if (captcha) body.captcha = captcha; + const res = await post(`${AS_BASE}/user/oauth2/v2/grant`, + body, deviceHeaders()); + const grant = assertAs(await readJson(res, '授权查询账号绑定'), '授权查询账号绑定', 'accountGrant'); + if (!grant.token) throw new SklandError('账号绑定授权未返回 token', 'no_token', 'getAccountBinding'); + const query = new URLSearchParams({ token: grant.token, appCode: 'arknights' }); + const response = await fetchImpl(`${BINDING_BASE}/account/binding/v1/binding_list?${query}`, { headers: baseHeaders }); + const binding = assertAs(await readJson(response, '查询明日方舟账号绑定'), '查询明日方舟账号绑定'); + if (!Array.isArray(binding.list)) throw new SklandError('账号绑定响应缺少角色列表', 'bad_binding', 'getAccountBinding'); + return binding; + } + + // ── token → 授权码 → cred → 绑定 ────────────────────────────────────────────────────────── + + /** + * access_token (+ deviceToken when the login path provided one) → grant code. + * @param {string} accessToken + * @param {string | null} [deviceToken] + * @returns {Promise} the grant code + */ + async function getGrantCode(accessToken, deviceToken = null) { + const body = { appCode: SKLAND_APP_CODE, token: accessToken, type: 0 }; + if (deviceToken) body.deviceToken = deviceToken; + const res = await post(`${AS_BASE}/user/oauth2/v2/grant`, body); + const json = await readJson(res, '换取授权码'); + return assertAs(json, '换取授权码').code; + } + + /** grant code → { cred, credToken } (this endpoint takes no signature). */ + async function getCred(code) { + const res = await post(`${ZONAI_BASE}/api/v1/user/auth/generate_cred_by_code`, { code, kind: 1 }); + const json = await readJson(res, '换取 cred'); + const data = assertZonai(json, '换取 cred'); + return { cred: data.cred, credToken: data.token }; + } + + /** A signed GET on zonai (the signature lives in crypto.js). */ + async function signedGet(path, query, cred, credToken, dId) { + const { headers } = signedHeaders({ cred, credToken, dId, method: 'GET', path, query }); + const res = await fetchImpl(`${ZONAI_BASE}${path}${query ? `?${query}` : ''}`, { headers: { ...baseHeaders, ...headers } }); + return readJson(res, path); + } + + /** A signed POST on zonai (attendance etc.). */ + async function signedPost(path, body, cred, credToken, dId) { + const { headers } = signedHeaders({ cred, credToken, dId, method: 'POST', path, body }); + const res = await post(`${ZONAI_BASE}${path}`, body, headers); + return readJson(res, path); + } + + /** + * The account's bound games / roles, refreshing the credential once when the upstream says it expired. + * @param {{ cred: string, credToken: string, dId: string, accessToken?: string | null, deviceToken?: string | null }} c + * @returns {Promise<{ binding: object, cred: string, credToken: string }>} the (possibly refreshed) credentials + */ + async function getBinding(c) { + let { cred, credToken } = c; + const dId = c.dId; + let json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId); + if (json.code === 10000) { + const res = await post(`${ZONAI_BASE}/api/v1/auth/refresh`, {}, { cred }); + const data = assertZonai(await readJson(res, '刷新 cred_token'), '刷新 cred_token'); + credToken = data.token; + json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId); + } + if (json.code === 10002 && (c.accessToken || c.deviceToken)) { + const grantCode = await getGrantCode(c.accessToken, c.deviceToken); + const fresh = await getCred(grantCode); + cred = fresh.cred; + credToken = fresh.credToken; + json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId); + } + if (json.code !== 0) { + const message = json.message || json.msg || ''; + if (/未登录/.test(message)) throw new SklandError('登录已过期,请重新绑定', json.code, 'getBinding'); + throw new SklandError(message || '查询绑定失败', json.code, 'getBinding'); + } + return { binding: json.data, cred, credToken }; + } + + // ── 整链快捷方式(service.js 用它们组装每个玩家的登录) ────────────────────────────────── + + /** 扫码整链: scanUrl → scanCode → token (+deviceToken) → grant → cred. */ + async function loginByScan({ onWait } = {}) { + const { scanId, scanUrl } = await genScan(); + const scanCode = await pollScan(scanId, { onWait }); + const auth = await tokenByScanCode(scanCode); + const grantCode = await getGrantCode(auth.token, auth.deviceToken); + const { cred, credToken } = await getCred(grantCode); + return { scanId, scanUrl, ...auth, cred, credToken }; + } + + /** 验证码整链: phone + code → token (+hgId+deviceToken) → grant → cred. */ + async function loginByPhoneCode(phone, code) { + const auth = await tokenByPhoneCode(phone, code); + const grantCode = await getGrantCode(auth.token, auth.deviceToken); + const { cred, credToken } = await getCred(grantCode); + return { ...auth, cred, credToken }; + } + + /** + * Password → HG account token → binding-service token (type 1) → Arknights roles. + * This flow does not acquire a Skland cred or require a stored deviceToken. + */ + async function loginByPhonePassword(phone, password) { + const { token } = await tokenByPhonePassword(phone, password); + const binding = await getAccountBinding(token); + return { token, binding }; + } + + return { + genScan, checkScan, pollScan, tokenByScanCode, + sendPhoneCode, tokenByPhoneCode, tokenByPhonePassword, + getGrantCode, getCred, getBinding, getAccountBinding, signedGet, signedPost, + loginByScan, loginByPhoneCode, loginByPhonePassword, + }; +} diff --git a/server/skland/crypto.js b/server/skland/crypto.js new file mode 100644 index 00000000..45923d1e --- /dev/null +++ b/server/skland/crypto.js @@ -0,0 +1,105 @@ +// server/skland/crypto.js — the Skland API request signature (docs/PACKS.md §4 keeps packs data-only; this module is +// server code, not a pack). Same algorithm the community clients use, reconstructed from the public descriptions: +// +// header_ca = { platform: '3', timestamp: '', dId: '', vName: '1.0.0' } +// GET: raw = path + query + timestamp + compactJson(header_ca) +// POST: raw = path + compactJson(body) + timestamp + compactJson(header_ca) +// sign = md5( hmacSha256Hex(credToken, raw) ) +// +// Only Web Crypto / node:crypto are used; no third-party dependency. Pure functions — fully unit-tested in +// test/skland/crypto.test.js against pinned vectors. + +import { createHmac, createHash, randomUUID, randomBytes } from 'node:crypto'; + +/** The Skland mobile client's appCode (public SDK constant, visible in decompiled clients). */ +export const SKLAND_APP_CODE = '4ca99fa6b56cc2ba'; +/** User-Agent the upstream expects (a Skland Android client). */ +export const SKLAND_UA = 'Skland/1.32.1 (com.hypergryph.skland; build:103201004; Android 33; ) Okhttp/4.11.0'; + +/** Compact JSON: no spaces, key order preserved (the upstream signs the exact body bytes). */ +export function compactJson(value) { + return JSON.stringify(value); +} + +/** + * Build the signed header set for one Skland API request. + * @param {{ + * cred: string, credToken: string, dId: string, + * method: 'GET' | 'POST', path: string, + * query?: string, body?: object | string, + * timestampSeconds?: number, + * }} p + * @returns {{ headers: Record, signInput: string }} + */ +export function signedHeaders(p) { + const timestampSeconds = p.timestampSeconds ?? Math.floor(Date.now() / 1000) - 2; + const ts = String(timestampSeconds); + const headerCa = { platform: '3', timestamp: ts, dId: p.dId, vName: '1.0.0' }; + const caJson = compactJson(headerCa); + const method = p.method.toUpperCase(); + const bodyJson = method === 'POST' + ? compactJson(p.body ?? {}) + : ''; + const query = p.query || ''; + const signInput = method === 'POST' + ? `${p.path}${bodyJson}${ts}${caJson}` + : `${p.path}${query}${ts}${caJson}`; + const sign = md5(hmacSha256Hex(p.credToken, signInput)); + return { + signInput, + headers: { + 'User-Agent': SKLAND_UA, + 'Accept-Encoding': 'gzip', + Connection: 'close', + 'X-Requested-With': 'com.hypergryph.skland', + cred: p.cred, + dId: p.dId, + platform: '3', + timestamp: ts, + vName: '1.0.0', + sign, + }, + }; +} + +/** HMAC-SHA256, lowercase hex digest. */ +export function hmacSha256Hex(key, message) { + return createHmac('sha256', key).update(message, 'utf8').digest('hex'); +} + +/** MD5, lowercase hex digest. */ +export function md5(text) { + return createHash('md5').update(text, 'utf8').digest('hex'); +} + +/** + * A device fingerprint (`dId`). The upstream accepts any stable opaque value here; the community clients derive + * one from a random UUID, cache it and reuse it for the account's lifetime. `B` + 32 hex mirrors their format. + * @returns {string} + */ +export function makeDeviceId() { + return `B${randomBytes(16).toString('hex')}`; +} + +/** A fresh scan session id placeholder (not a real upstream value — tests only). */ +export function randomId() { + return randomUUID().replace(/-/g, ''); +} + +/** + * The account/login device headers (as.hypergryph.com 验证码 / 密码 / 扫码登录都要求): + * `X-DeviceId` random ~10 chars, `X-DeviceModel` a device name, `X-DeviceType: 2` (mobile). + * The device id is stable per process so the upstream sees one device for the server's lifetime. + * @returns {{ 'X-DeviceId': string, 'X-DeviceModel': string, 'X-DeviceType': string }} + */ +export function deviceHeaders() { + if (!deviceHeaders.cache) { + deviceHeaders.cache = Object.freeze({ + 'X-DeviceId': randomBytes(5).toString('hex'), // 10 hex chars + 'X-DeviceModel': 'Stronghold-Protocol Server', + 'X-DeviceType': '2', + }); + } + return deviceHeaders.cache; +} +deviceHeaders.cache = null; diff --git a/server/skland/http.js b/server/skland/http.js new file mode 100644 index 00000000..c7b46dea --- /dev/null +++ b/server/skland/http.js @@ -0,0 +1,158 @@ +// server/skland/http.js — the /skland/* HTTP surface (JSON). Wired into the main request listener by routes.js when +// (i18n-ignore-file: the answers are bilingual by design, 中文 · English, the same convention as server/http/* +// — docs/I18N.md; the panel renders its own labels and shows these strings verbatim as the diagnostic) +// the skland module is enabled (opts.skland / env SP_SKLAND=1). **Every** answer of this surface is JSON, including +// the errors: a JSON client must never be handed the module-level HTML error page (http/common.js sendError), so a +// local `fail()` writes { ok: false, error } with the status instead. POST bodies are read with a size cap; the +// security headers come from the outer listener. +// +// Endpoints (docs/skland.md): +// GET /skland/status?player=… the player's binding state +// POST /skland/scan/start {player} start a scan session → { scanId, scanUrl } +// POST /skland/scan/poll {player} one poll; finishes the login when the phone confirmed +// POST /skland/phone/send {player, phone, captcha?} send the SMS code +// POST /skland/phone/login {player, phone, code} 验证码 login → bind +// POST /skland/password/login {player, phone, password} 密码 login → bind +// POST /skland/unbind {player} forget the credentials +// GET /skland/roles?player=…&refresh=1 the bound arknights roles (cached) + +import { sendJson } from '../http/common.js'; +import { SESSION_BINDING_PREFIX } from './session.js'; + +/** The cap on a POST body (a phone + code + player id is small). */ +const MAX_BODY = 64 * 1024; + +/** Read the request body as JSON (async iterable; node:http IncomingMessage supports it). */ +export async function readJsonBody(req) { + let size = 0; + const chunks = []; + for await (const chunk of req) { + size += chunk.length; + if (size > MAX_BODY) throw new Error('body too large'); + chunks.push(chunk); + } + if (!chunks.length) return {}; + return JSON.parse(Buffer.concat(chunks).toString('utf8')); +} + +/** + * @param {{ service: ReturnType, sessionPlayer?: Function }} deps + * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, + * parts: { rawPath: string, query: string }) => Promise} + * resolves true when the request was a /skland/* route (handled here). + */ +export function createSklandHttp({ service, sessionPlayer }) { + const queryParam = (parts, name) => { + for (const pair of parts.query.split('&')) { + const [k, v] = pair.split('='); + if (decodeURIComponent(k || '') === name) return decodeURIComponent(v || ''); + } + return ''; + }; + + /** Every failure of this surface is JSON — never the module-level HTML error page. */ + const fail = (req, res, status, error) => sendJson(req, res, status, { ok: false, error }); + + async function handle(req, res, parts) { + if (parts.rawPath !== '/skland' && !parts.rawPath.startsWith('/skland/')) return false; + const scoped = parts.rawPath.startsWith('/skland/session/'); + const path = scoped ? parts.rawPath.replace('/skland/session/', '/skland/') : parts.rawPath; + const ownedPlayer = scoped ? sessionPlayer?.(req) : null; + if (scoped && !ownedPlayer) { + fail(req, res, 401, '游戏会话已失效,请重新连接 · Game session expired'); return true; + } + const allowed = (player) => scoped || (!player.startsWith(SESSION_BINDING_PREFIX) && !player.startsWith('@account/')); + try { + if (req.method === 'GET' && path === '/skland/status') { + const player = ownedPlayer || queryParam(parts, 'player') || ''; + if (!allowed(player)) { fail(req, res, 403, '需要游戏会话 · Game session required'); return true; } + if (!player) { fail(req, res, 400, '缺少 player 参数 · Missing player'); return true; } + sendJson(req, res, 200, service.status(player)); + return true; + } + if (req.method === 'GET' && path === '/skland/roles') { + const player = ownedPlayer || queryParam(parts, 'player') || ''; + if (!allowed(player)) { fail(req, res, 403, '需要游戏会话 · Game session required'); return true; } + const refresh = queryParam(parts, 'refresh') === '1'; + if (!player) { fail(req, res, 400, '缺少 player 参数 · Missing player'); return true; } + try { + sendJson(req, res, 200, { ok: true, roles: await service.roles(player, refresh) }); + } catch (e) { + sendJson(req, res, 200, { ok: false, error: e.message }); + } + return true; + } + if (req.method !== 'POST') { fail(req, res, 405, '不支持的请求方法 · Method not allowed'); return true; } + + let body; + try { body = await readJsonBody(req); } catch { + fail(req, res, 400, '请求体无效 · Bad request'); return true; + } + const player = ownedPlayer || String(body.player || ''); + if (!allowed(player)) { fail(req, res, 403, '需要游戏会话 · Game session required'); return true; } + + if (path === '/skland/scan/start') { + if (!player) { fail(req, res, 400, '缺少 player · Missing player'); return true; } + try { + const qr = await service.startScan(player); + sendJson(req, res, 200, { ok: true, ...qr }); + } catch (e) { sendJson(req, res, 200, { ok: false, error: e.message }); } + return true; + } + if (path === '/skland/scan/poll') { + if (!player) { fail(req, res, 400, '缺少 player · Missing player'); return true; } + try { + sendJson(req, res, 200, { ok: true, ...(await service.pollScan(player)) }); + } catch (e) { + // A poll that fails still names a terminal state: a caller stops on anything but 'waiting', and a bare + // { ok: false } would leave it reading `state === undefined` (the panel showed "未知状态" for that). + sendJson(req, res, 200, { ok: false, state: 'error', error: e.message }); + } + return true; + } + if (path === '/skland/phone/send') { + if (!player || !body.phone) { fail(req, res, 400, '缺少参数 · Missing fields'); return true; } + try { + sendJson(req, res, 200, await service.sendPhoneCode(player, String(body.phone), body.captcha)); + } catch { fail(req, res, 500, '服务器内部错误 · Internal error'); } + return true; + } + if (path === '/skland/phone/login') { + if (!player || !body.phone || !body.code) { fail(req, res, 400, '缺少参数 · Missing fields'); return true; } + sendJson(req, res, 200, await service.loginByPhoneCode(player, String(body.phone), String(body.code))); + return true; + } + if (path === '/skland/password/login') { + if (!player || !body.phone || !body.password) { fail(req, res, 400, '缺少参数 · Missing fields'); return true; } + sendJson(req, res, 200, await service.loginByPhonePassword(player, String(body.phone), String(body.password), + body.captcha, typeof body.captchaStep === 'string' ? body.captchaStep : undefined)); + return true; + } + if (path === '/skland/unbind') { + if (!player) { fail(req, res, 400, '缺少 player · Missing player'); return true; } + sendJson(req, res, 200, service.unbind(player)); + return true; + } + fail(req, res, 404, '未知的 /skland 接口 · Unknown endpoint'); + } catch { + fail(req, res, 500, '服务器内部错误 · Internal error'); + } + return true; + } + return handle; +} + +/** + * The handler mounted when the module is OFF. Without it the path would fall through to the static mount (a 404 for + * GET) and to routes.js's GET/HEAD gate (a **405 HTML** for POST) — an inconsistent surface for one API. This one + * answers every /skland/* request the same way, JSON like the enabled surface, so a client can always parse the + * "not enabled" answer. + * @returns {(req, res, parts: { rawPath: string }) => Promise} + */ +export function createSklandOffHandler() { + return async (req, res, parts) => { + if (parts.rawPath !== '/skland' && !parts.rawPath.startsWith('/skland/')) return false; + sendJson(req, res, 404, { ok: false, error: '未启用森空岛绑定 · the Skland module is off (set SP_SKLAND=1)' }); + return true; + }; +} diff --git a/server/skland/recovery.js b/server/skland/recovery.js new file mode 100644 index 00000000..3904ac0e --- /dev/null +++ b/server/skland/recovery.js @@ -0,0 +1,22 @@ +// Account recovery describes only the selected role's live server-side seat. It never reads browser caches. +import { randomUUID } from 'node:crypto'; + +export function createAccountRecovery({ registry, lobby }) { + const roomIds = new WeakMap(); + return { + describe(playerId) { + const session = playerId && registry.byId(playerId); + if (!session || registry.isExpired(session, registry.now())) return null; + const room = lobby.roomOf(session); + if (!room || room.disposed) return null; + if (!roomIds.has(room)) roomIds.set(room, randomUUID()); + return { + // A selector, not a credential: a replaced room or a later match must not satisfy a stale recovery request. + id: roomIds.get(room) + ':' + room.matchCount, + name: session.name, code: room.code, mode: room.mode, difficulty: room.difficulty, + inMatch: !!room.match, round: room.match?.round ?? 0, + connected: session.connected, spectating: !!room.spectatorOf(playerId), + }; + }, + }; +} diff --git a/server/skland/service.js b/server/skland/service.js new file mode 100644 index 00000000..8eb920c8 --- /dev/null +++ b/server/skland/service.js @@ -0,0 +1,285 @@ +// server/skland/service.js — per-player Arknights account bindings on top of the client (client.js) and the +// (i18n-ignore-file: the Chinese strings are error/diagnostic text (an upstream failure's message, a login that did +// not finish) returned as `{ ok: false, error }` — docs/I18N.md's "Error messages are not player text"; the panel +// supplies the wording a player reads) +// store (store.js). One instance per server. The scan sessions live in memory (a QR code is transient); everything +// durable lives in the store. +// +// HTTP uses transport-scoped pending rows, then JWT-owned account rows after provider confirmation. +// Diagnostic ids require an administrator key and cannot access either reserved namespace (docs/skland.md §5). + +import { createSklandClient, SklandError, SklandCaptchaError, SklandNeedDeviceError } from './client.js'; +import { makeDeviceId } from './crypto.js'; + +/** Extracts a readable profile name from the binding answer (an arknights channel first, then any). */ +function nicknameFromBinding(binding) { + const lists = binding?.list || []; + const ordered = [...lists.filter((l) => l?.appCode === 'arknights'), ...lists.filter((l) => l?.appCode !== 'arknights')]; + for (const list of ordered) { + for (const b of list?.bindingList || []) { + if (b.nickName) return b.nickName; + } + } + return null; +} + +/** The arknights characters of the binding answer, as a stable shape (every field always present, so JSON keeps it). + * `uid` is the in-game account id, `channelName` the channel, `isDefault` its flag, `gameId` the game's own id. */ +function arknightsRoles(binding) { + const roles = []; + for (const list of binding?.list || []) { + if (list?.appCode !== 'arknights') continue; + for (const b of list?.bindingList || []) { + roles.push({ + uid: b.uid == null ? null : String(b.uid), + nickName: b.nickName ?? null, + channelName: b.channelName ?? null, + isDefault: b.is_default === true || b.isDefault === true, + gameId: b.gameId ?? null, + }); + } + } + return roles; +} + +/** + * @param {{ + * store: ReturnType, + * client?: ReturnType, + * fetchImpl?: typeof fetch, + * scanTimeoutMs?: number, + * }} p + */ +export function createSklandService({ store, client, fetchImpl, scanTimeoutMs = 130_000 }) { + const skland = client || createSklandClient({ fetchImpl }); + /** @type {Map} in-memory scan sessions by player */ + const scans = new Map(); + // Resume the same grant after its captcha; never cache passwords or repeat a successful password exchange. + const pendingPasswordGrants = new Map(); + const attempts = new Map(); + function begin(playerId) { + const attempt = {}; + attempts.set(playerId, attempt); scans.delete(playerId); + return attempt; + } + const current = (playerId, attempt) => attempts.get(playerId) === attempt; + function checkCurrent(playerId, attempt) { + if (!current(playerId, attempt)) { + const error = new Error('登录已取消,请重试'); + throw error; + } + } + function retire(playerId, attempt) { if (current(playerId, attempt)) attempts.delete(playerId); } + + function now() { return Date.now(); } + + /** The player's row, created when missing. */ + function row(playerId) { + store.ensure(playerId); + return store.get(playerId); + } + + /** GET /skland/status body. */ + function status(playerId) { + const r = row(playerId); + const roles = r.binding ? arknightsRoles(r.binding) : []; + return { + playerId, + status: r.status, + bound: r.status === 'bound', + nickname: r.nickname, + hgId: r.hg_id, + roles, + error: r.error || undefined, + }; + } + + /** Starts (or restarts) a QR login session for the player. The QR code encodes `scanUrl`. */ + async function startScan(playerId) { + const attempt = begin(playerId); + pendingPasswordGrants.delete(playerId); + try { + const { scanId, scanUrl } = await skland.genScan(); + checkCurrent(playerId, attempt); + scans.set(playerId, { scanId, scanUrl, createdAt: now(), attempt, polling: false }); + store.update(playerId, { status: 'scanning', error: null }); + return { scanId, scanUrl, expiresInMs: scanTimeoutMs }; + } catch (error) { retire(playerId, attempt); throw error; } + } + + /** + * One poll of the player's scan session. When the phone confirmed, the rest of the chain runs and the player is + * bound — the same call that reports `confirmed` also completes the login. + * @returns {Promise<{ state: 'none' | 'waiting' | 'confirmed' | 'expired', scanUrl?: string, error?: string, + * awaitingConfirmation?: boolean, status?: string, nickname?: string | null }>} + */ + async function pollScan(playerId) { + const session = scans.get(playerId); + if (!session) return { state: 'none' }; + if (now() - session.createdAt > scanTimeoutMs) { + scans.delete(playerId); + retire(playerId, session.attempt); + store.update(playerId, { status: 'unbound', error: '扫码超时' }); + return { state: 'expired', error: '扫码超时,请重新获取二维码' }; + } + if (session.polling) return { state: 'waiting', scanUrl: session.scanUrl }; + session.polling = true; + let confirmed = false; + try { + const { state, scanCode, awaitingConfirmation } = await skland.checkScan(session.scanId); + checkCurrent(playerId, session.attempt); + if (state === 'waiting') return { state, scanUrl: session.scanUrl, awaitingConfirmation: !!awaitingConfirmation }; + confirmed = true; + const auth = await skland.tokenByScanCode(scanCode); + await finishLogin(playerId, auth, { attempt: session.attempt }); + const r = row(playerId); + scans.delete(playerId); retire(playerId, session.attempt); + return { state: 'confirmed', status: r.status, nickname: r.nickname }; + } catch (e) { + if (!current(playerId, session.attempt)) return { state: 'none' }; + scans.delete(playerId); retire(playerId, session.attempt); + store.update(playerId, { status: 'error', error: e.message }); + if (!confirmed) throw e; + return { state: 'confirmed', error: e.message }; + } finally { session.polling = false; } + } + + /** + * Shared tail of every login path: grant → cred → binding, persisted per player. + * @param {string} playerId + * @param {{ token: string, hgId?: string | null, deviceToken?: string | null }} auth + * @param {{ dId?: string, attempt?: object }} [o] + */ + async function finishLogin(playerId, auth, o = {}) { + const existing = row(playerId); + const dId = existing.d_id || o.dId || makeDeviceId(); + const grantCode = await skland.getGrantCode(auth.token, auth.deviceToken); + const { cred, credToken } = await skland.getCred(grantCode); + const { binding, cred: cred2, credToken: credToken2 } = await skland.getBinding({ + cred, credToken, dId, accessToken: auth.token, deviceToken: auth.deviceToken, + }); + if (o.attempt) checkCurrent(playerId, o.attempt); + store.update(playerId, { + access_token: auth.token, device_token: auth.deviceToken || existing.device_token || null, + hg_id: auth.hgId || existing.hg_id || null, + cred: cred2, cred_token: credToken2, d_id: dId, binding, binding_source: 'skland', + nickname: nicknameFromBinding(binding), status: 'bound', error: null, + }); + return row(playerId); + } + + /** 发送短信验证码. `captcha` = the solved geetest fields when the first attempt asked for one. */ + async function sendPhoneCode(playerId, phone, captcha) { + try { + await skland.sendPhoneCode(phone, captcha); + store.update(playerId, { error: null }); + return { ok: true }; + } catch (e) { + if (e instanceof SklandCaptchaError) return { ok: false, needCaptcha: true, captcha: e.captcha }; + if (e instanceof SklandError) return { ok: false, error: e.message }; + throw e; + } + } + + /** 验证码登录: phone + code → bind. Uses the step methods so `finishLogin` is the only grant→cred→binding place. */ + async function loginByPhoneCode(playerId, phone, code) { + const attempt = begin(playerId); + pendingPasswordGrants.delete(playerId); + try { + const auth = await skland.tokenByPhoneCode(phone, code); + const r = await finishLogin(playerId, auth, { attempt }); + return { ok: true, nickname: r.nickname, roles: arknightsRoles(r.binding) }; + } catch (e) { + if (!current(playerId, attempt)) return { ok: false, error: '登录已取消,请重试' }; + const message = e instanceof SklandError ? e.message : '登录失败'; + store.update(playerId, { status: 'error', error: message }); + return { ok: false, error: message }; + } finally { retire(playerId, attempt); } + } + + /** + * Password login queries the official HG account binding service directly, without acquiring a Skland cred. + * A previous QR/SMS login and its deviceToken are not needed for this flow. + */ + async function loginByPhonePassword(playerId, phone, password, captcha, captchaStep = 'password') { + const attempt = begin(playerId); + let auth; + try { + if (captchaStep === 'accountGrant' && captcha) { + const pending = pendingPasswordGrants.get(playerId); + pendingPasswordGrants.delete(playerId); + if (!pending || pending.phone !== phone || now() - pending.createdAt > 120_000) { + throw new SklandError('验证已过期,请重新点击登录', 'captcha_expired', 'accountGrant'); + } + auth = { token: pending.token }; + } else { + pendingPasswordGrants.delete(playerId); + auth = await skland.tokenByPhonePassword(phone, password, captcha); + } + const { token } = auth; + const binding = await skland.getAccountBinding(token, captchaStep === 'accountGrant' ? captcha : undefined); + checkCurrent(playerId, attempt); + store.update(playerId, { + access_token: token, device_token: null, hg_id: null, cred: null, cred_token: null, + user_id: null, d_id: null, binding, binding_source: 'hypergryph', + nickname: nicknameFromBinding(binding), status: 'bound', error: null, + }); + const r = row(playerId); + return { ok: true, nickname: r.nickname, roles: arknightsRoles(r.binding) }; + } catch (e) { + if (!current(playerId, attempt)) return { ok: false, error: '登录已取消,请重试' }; + if (e instanceof SklandCaptchaError) { + if (e.step === 'accountGrant' && auth) pendingPasswordGrants.set(playerId, { token: auth.token, phone, createdAt: now() }); + return { ok: false, needCaptcha: true, captcha: e.captcha, captchaStep: e.step }; + } + if (e instanceof SklandNeedDeviceError) { + store.update(playerId, { status: 'error', error: e.message }); + return { ok: false, error: e.message, needDevice: true }; + } + const message = e instanceof SklandError ? e.message : '登录失败'; + store.update(playerId, { status: 'error', error: message }); + return { ok: false, error: message }; + } finally { retire(playerId, attempt); } + } + + /** 解绑: forget every credential, including the deviceToken; later logins are checked by the upstream again. */ + function unbind(playerId) { + store.unbind(playerId); + scans.delete(playerId); + pendingPasswordGrants.delete(playerId); + attempts.delete(playerId); + return { ok: true }; + } + + /** The cached arknights roles (a fresh /player/binding round-trip when `refresh`). */ + async function roles(playerId, refresh = false) { + const r = row(playerId); + if (!refresh || r.status !== 'bound') return arknightsRoles(r.binding); + const attempt = begin(playerId); + try { + if (r.binding_source === 'hypergryph') { + const binding = await skland.getAccountBinding(r.access_token); + checkCurrent(playerId, attempt); + store.update(playerId, { binding, nickname: nicknameFromBinding(binding), error: null }); + return arknightsRoles(binding); + } + const { binding, cred, credToken } = await skland.getBinding({ + cred: r.cred, credToken: r.cred_token, dId: r.d_id, accessToken: r.access_token, deviceToken: r.device_token, + }); + checkCurrent(playerId, attempt); + store.update(playerId, { binding, cred, credToken, nickname: nicknameFromBinding(binding) }); + return arknightsRoles(binding); + } catch (e) { + if (!current(playerId, attempt)) throw e; + const message = e instanceof SklandError ? e.message : '查询失败'; + store.update(playerId, { status: 'error', error: message }); + throw e; + } finally { retire(playerId, attempt); } + } + + return { + status, startScan, pollScan, sendPhoneCode, loginByPhoneCode, loginByPhonePassword, unbind, roles, + /** test hooks */ + _store: store, _client: skland, _scans: scans, + }; +} diff --git a/server/skland/session.js b/server/skland/session.js new file mode 100644 index 00000000..a0207d76 --- /dev/null +++ b/server/skland/session.js @@ -0,0 +1,10 @@ +// The title integration reuses the game's existing reconnect token. No new WebSocket messages or session fields. +export const SESSION_BINDING_PREFIX = '@session/'; + +export function createSessionPlayerResolver(registry) { + return (req) => { + const match = /^Bearer ([a-f0-9]{32})$/i.exec(req.headers.authorization || ''); + const session = match ? registry.byToken(match[1]) : null; + return session ? SESSION_BINDING_PREFIX + session.playerId : null; + }; +} diff --git a/server/skland/store.js b/server/skland/store.js new file mode 100644 index 00000000..7feaa0ba --- /dev/null +++ b/server/skland/store.js @@ -0,0 +1,179 @@ +// server/skland/store.js — the per-player Skland credential store (docs/PACKS.md keeps packs data-only; this is +// server code, not a pack). A local, high-performance embedded database: node:sqlite (bundled with Node ≥ 22) in +// WAL mode — no native build step, no extra dependency, survives restarts, and the Docker image mounts one file. +// +// One row per pending transport, JWT account subject or administrator diagnostic id, holding the login chain state: +// access_token the HG account token (refreshes official bindings or rebuilds a Skland cred) +// cred the long-term API credential +// cred_token the HMAC signing key (never leaves the server) +// user_id the Skland internal user id +// d_id the device fingerprint the credentials are pinned to +// nickname / binding what the account reports (cache; refreshed on demand) +// binding_source selects official HG account lookup or the legacy Skland refresh path +// +// The store is synchronous (SQLite is), so callers never await; it is created per server instance (tests pass their +// own file, `:memory:` for pure in-memory). Reserved keys and account ownership are enforced by accountHttp.js. + +import { DatabaseSync } from 'node:sqlite'; +import { mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS skland_players ( + player_id TEXT PRIMARY KEY, + access_token TEXT, + device_token TEXT, + hg_id TEXT, + cred TEXT, + cred_token TEXT, + user_id TEXT, + d_id TEXT, + nickname TEXT, + binding TEXT, -- the raw binding list from the selected provider (cache) + binding_source TEXT NOT NULL DEFAULT 'skland', -- skland | hypergryph + status TEXT NOT NULL DEFAULT 'unbound', -- unbound | scanning | bound | error + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_skland_status ON skland_players(status); +`; + +/** + * Node ≥ 22.5 has node:sqlite; an older 22.x has not. The store falls back to an in-memory map so the server still + * boots (the credentials then do not survive a restart — logged once). + * @param {{ file?: string, log?: object }} [opts] file: the database path (default data/skland.db; ':memory:' for + * tests). The parent directory is created when missing. + */ +export function createSklandStore(opts = {}) { + const file = opts.file || join(process.cwd(), 'data', 'skland.db'); + const log = opts.log || { warn: () => {} }; + if (typeof DatabaseSync !== 'function') { + log.warn?.('[skland] node:sqlite unavailable on this Node — using an in-memory store (credentials lost on restart)'); + return createMemoryStore(file); + } + if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true }); + const db = new DatabaseSync(file); + db.exec('PRAGMA journal_mode = WAL;'); + db.exec('PRAGMA synchronous = NORMAL;'); + db.exec('PRAGMA foreign_keys = ON;'); + db.exec(SCHEMA); + // Preserve existing QR/SMS bindings when opening a database created before official account lookup was added. + if (!db.prepare('PRAGMA table_info(skland_players)').all().some((column) => column.name === 'binding_source')) { + db.exec("ALTER TABLE skland_players ADD COLUMN binding_source TEXT NOT NULL DEFAULT 'skland'"); + } + + const now = () => Date.now(); + + /** @returns {Record | null} the row as an object, or null */ + function rowToObject(row) { + if (!row) return null; + return { ...row, binding: row.binding ? JSON.parse(row.binding) : null }; + } + + return { + /** The database file (or ':memory:'). */ + file, + /** The SQLite journal mode actually in force ('wal' on a file-backed store; 'memory' for the fallback). */ + journalMode() { + try { return String(db.prepare('PRAGMA journal_mode').get().journal_mode).toLowerCase(); } catch { return 'unknown'; } + }, + close() { db.close(); }, + + /** Ensure a row exists for the player (idempotent). */ + ensure(playerId) { + db.prepare( + 'INSERT INTO skland_players (player_id, status, created_at, updated_at) VALUES (?, ?, ?, ?) ' + + 'ON CONFLICT(player_id) DO NOTHING', + ).run(playerId, 'unbound', now(), now()); + }, + + get(playerId) { + const row = db.prepare('SELECT * FROM skland_players WHERE player_id = ?').get(playerId); + return rowToObject(row); + }, + + /** All rows (admin/debug views). */ + list() { + return db.prepare('SELECT * FROM skland_players ORDER BY updated_at DESC').all().map(rowToObject); + }, + + /** + * Merge fields into a player's row (created when missing). `binding` may be an object — stored as JSON. + * @param {string} playerId + * @param {Partial<{access_token: string, device_token: string, hg_id: string, cred: string, cred_token: string, + * user_id: string, d_id: string, nickname: string, binding: object | null, binding_source: string, status: string, + * error: string | null}>} fields + */ + update(playerId, fields) { + this.ensure(playerId); + const allowed = ['access_token', 'device_token', 'hg_id', 'cred', 'cred_token', 'user_id', 'd_id', 'nickname', 'binding_source', 'status', 'error']; + const sets = []; + const values = []; + for (const key of allowed) { + if (key in fields) { sets.push(`${key} = ?`); values.push(fields[key] ?? null); } + } + if ('binding' in fields) { sets.push('binding = ?'); values.push(fields.binding == null ? null : JSON.stringify(fields.binding)); } + if (!sets.length) return; + sets.push('updated_at = ?'); + values.push(now(), playerId); + db.prepare(`UPDATE skland_players SET ${sets.join(', ')} WHERE player_id = ?`).run(...values); + }, + + /** Remove a player's credentials (解绑). The row stays, marked unbound. */ + unbind(playerId) { + this.update(playerId, { + access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null, + user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null, + }); + }, + + /** Delete a player's row entirely (e.g. session cleanup). */ + remove(playerId) { + db.prepare('DELETE FROM skland_players WHERE player_id = ?').run(playerId); + }, + + /** For tests: raw row count. */ + count() { + return db.prepare('SELECT COUNT(*) AS n FROM skland_players').get().n; + }, + }; +} + +/** The in-memory fallback (same API; a pre-22.5 Node). Credentials do not survive a restart. */ +function createMemoryStore(file) { + /** @type {Map>} */ + const rows = new Map(); + const now = () => Date.now(); + const blank = () => ({ + player_id: null, access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null, + user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null, + created_at: 0, updated_at: 0, + }); + return { + file, + journalMode() { return 'memory'; }, + close() { rows.clear(); }, + ensure(playerId) { + if (!rows.has(playerId)) rows.set(playerId, { ...blank(), player_id: playerId, created_at: now(), updated_at: now() }); + }, + get(playerId) { const row = rows.get(playerId); return row ? { ...row } : null; }, + list() { return [...rows.values()].map((r) => ({ ...r })); }, + update(playerId, fields) { + this.ensure(playerId); + const row = rows.get(playerId); + for (const [key, value] of Object.entries(fields)) { + row[key] = key === 'binding' && value != null ? JSON.parse(JSON.stringify(value)) : (value ?? null); + } + row.updated_at = now(); + }, + unbind(playerId) { + this.update(playerId, { + access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null, + user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null, + }); + }, + remove(playerId) { rows.delete(playerId); }, + count() { return rows.size; }, + }; +} diff --git a/shared/accountIdentity.js b/shared/accountIdentity.js new file mode 100644 index 00000000..c0ad8370 --- /dev/null +++ b/shared/accountIdentity.js @@ -0,0 +1,5 @@ +// A role's UID is unique within its provider-reported server region. Display names are never identity keys. +// gameId is optional provider metadata (the official password API may omit it), so it cannot be part of identity. +export const roleKey = (role) => JSON.stringify([String(role.channelName || '').trim(), String(role.uid).trim()]); +export const hasRoleIdentity = (role) => role?.uid != null && String(role.uid).trim() !== '' + && String(role.channelName || '').trim() !== ''; diff --git a/test/client-identity-session.test.js b/test/client-identity-session.test.js index 18a97e1f..44b83a15 100644 --- a/test/client-identity-session.test.js +++ b/test/client-identity-session.test.js @@ -141,3 +141,24 @@ test('live token ownership survives a duplicated tab, reload and reconnect', { t assert.equal(welcome.playerId, a.welcome.playerId); await assertOnline(h, [a2, b]); }); + + +test('explicit account tokens are tentative until welcome and do not overwrite the last established local identity', async () => { + const old = '1'.repeat(32), selected = '2'.repeat(32), attached = '3'.repeat(32); + const local = storage(), session = storage({ 'sp.token': old }); + const id = createIdentity({ local, session, channel: null }); + await id.init(); + id.saveToken(old); + assert.equal(id.stageToken(selected), true); + assert.equal(id.getToken(), selected); + assert.deepEqual(id.getTokenClaim(), { noReplace: false }); + assert.equal(session.getItem('sp.token'), old); + assert.deepEqual(JSON.parse(local.getItem('sp.tokens')), [old]); + assert.equal(id.stageToken(''), false); + assert.equal(id.getToken(), selected); + id.saveToken(attached); + assert.equal(session.getItem('sp.token'), attached); + assert.equal(id.getToken(), attached); + assert.ok(Number.isFinite(id.getTokenClaim().claimAt)); + assert.equal(JSON.parse(local.getItem('sp.tokens')).includes(selected), false, 'only the rotated welcome token is remembered'); +}); diff --git a/test/gitignore.test.js b/test/gitignore.test.js index b5cbb29c..5b003060 100644 --- a/test/gitignore.test.js +++ b/test/gitignore.test.js @@ -1,6 +1,7 @@ // .gitignore keeps per-machine state out of the repository (and so out of the release packages, which ship tracked // files only: tools/package.mjs). GitHub #258: a local npm cache (`npm_config_cache=./.npm-cache`, `npm ci --cache -// .npm-cache`) must never be committed by any contributor. +// .npm-cache`) must never be committed by any contributor. The opt-in Skland credential database (docs/skland.md) is +// the other per-machine secret: `data/skland.db` and the Docker volume path `skland-data/`. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -29,3 +30,15 @@ test('GitHub #258: the local npm cache (.npm-cache/) is ignored, like node_modul } assert.notEqual(ignored('package.json'), true, 'a tracked file is not'); }); + +test('the Skland credential database (docs/skland.md): the file, its WAL side files and the Docker volume path', () => { + for (const entry of ['data/skland.db', 'data/skland.db-wal', 'data/skland.db-shm', 'skland-data/']) { + assert.ok(lines.includes(entry), `.gitignore lists ${entry}`); + } + // the store's default path, the two side files node:sqlite/WAL writes next to it, and the bind-mount form a + // deployment may use instead of docker-compose.yml's named volume (docker.test.js pins the volume itself) + for (const rel of ['data/skland.db', 'data/skland.db-wal', 'data/skland.db-shm', 'skland-data/skland.db', 'skland-data/skland.db-wal']) { + const v = ignored(rel); + if (v !== null) assert.equal(v, true, `${rel} is ignored`); + } +}); diff --git a/test/skland/api.test.js b/test/skland/api.test.js new file mode 100644 index 00000000..b8ba47ed --- /dev/null +++ b/test/skland/api.test.js @@ -0,0 +1,68 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createBindingApi, ensureBindingSession, preferredRole, roleKey } from '../../public/js/skland/api.js'; + +test('the title client puts its game token only in an authorization header and never sends a player id', async () => { + const calls = []; + const request = createBindingApi({ token: 'private-game-token', fetchImpl: async (url, init) => { + calls.push({ url, init }); return { ok: true, status: 200, json: async () => ({ ok: true }) }; + } }); + await request('status'); await request('password/login', { phone: '13800000000', password: 'local-input' }); + assert.equal(calls[0].url, '/skland/session/status'); + assert.equal(calls[0].init.headers.authorization, 'Bearer private-game-token'); + assert.equal(calls[0].init.method, 'GET'); + assert.equal(calls[1].init.method, 'POST'); + assert.deepEqual(JSON.parse(calls[1].init.body), { phone: '13800000000', password: 'local-input' }); + assert.ok(calls.every(({ url, init }) => !url.includes('private-game-token') && !init.body?.includes('private-game-token'))); +}); + +test('the title client distinguishes disabled module, expired session, network and malformed responses', async () => { + for (const [status, code] of [[404, 'off'], [401, 'session']]) { + await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => ({ status }) })('status'), { code }); + } + await assert.rejects(createBindingApi()('status'), { code: 'session' }); + await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => { throw new Error('offline'); } })('status'), { code: 'network' }); + await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => ({ status: 200, ok: true, json: async () => { throw new Error(); } }) })('status'), { code: 'response' }); +}); + +test('binding starts hello without entering the game, reuses an online identity, and cleans up on failure', async () => { + const listeners = new Map(); + let current = null, connects = 0, sentName; + const identity = { init: async () => {}, getToken: () => current }; + const net = { + status: 'connected', + on: (event, fn) => { listeners.set(event, fn); return () => listeners.delete(event); }, + setName: (name) => { sentName = name; }, + connect: () => { connects++; setTimeout(() => { current = 'T'; listeners.get('welcome')?.({ token: 'T' }); }, 0); }, + }; + assert.equal(await ensureBindingSession({ net, identity, name: '博士代号' }), 'T'); + assert.equal(sentName, '博士代号'); assert.equal(listeners.size, 0); + net.status = 'online'; + assert.equal(await ensureBindingSession({ net, identity, name: '另一个名字' }), 'T'); + assert.equal(connects, 1); + net.status = 'connected'; net.connect = () => {}; + await assert.rejects(ensureBindingSession({ net, identity, name: '博士', timeoutMs: 1 }), { code: 'network' }); + assert.equal(listeners.size, 0); +}); + +test('nickname selection uses the default role, preserves a selected channel, and supports no roles', () => { + const a = { uid: '123', channelName: '官服', gameId: 1, nickName: '官服博士' }; + const b = { uid: '123', channelName: 'B服', gameId: 2, nickName: 'B服博士', isDefault: true }; + assert.equal(preferredRole([a, b]), b); + assert.equal(preferredRole([a, b], roleKey(a)), a); + assert.equal(preferredRole([a, b], 'removed-role'), b); + assert.equal(preferredRole([a]), a); + assert.equal(preferredRole([]), null); + assert.equal(preferredRole([{ uid: 'missing-name', isDefault: true }, { ...b, nickName: ' ' }, a]), a); + assert.equal(preferredRole([{ uid: 'missing-name' }]), null); + assert.equal(preferredRole([{ uid: null, channelName: '官服', nickName: '缺少身份' }]), null); + assert.equal(roleKey(a), roleKey({ ...a, uid: 123, gameId: null, nickName: '改名以后' }), 'nickname and optional gameId never change identity'); +}); + + +test('an expired recovery is distinguishable from a transport error and never becomes a successful entry', async () => { + const request = createBindingApi({ token: 'T', fetchImpl: async () => ({ status: 409, ok: false, + json: async () => ({ ok: false, code: 'resume_expired', error: 'expired' }), + }) }); + await assert.rejects(request('resume', { id: 'stale' }), { code: 'resume_expired' }); +}); diff --git a/test/skland/captcha.test.js b/test/skland/captcha.test.js new file mode 100644 index 00000000..0e456f0b --- /dev/null +++ b/test/skland/captcha.test.js @@ -0,0 +1,66 @@ +// Provider contract tests use a fake widget and fake proof only; no real captcha or network request is performed. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setImmediate } from 'node:timers/promises'; +import { solveCaptcha } from '../../public/js/skland/captcha.js'; + +function fakeProvider(proof) { + const events = {}; + const calls = { shown: 0, destroyed: 0, options: null }; + const widget = { + onReady(fn) { events.ready = fn; }, onSuccess(fn) { events.success = fn; }, + onClose(fn) { events.close = fn; }, onError(fn) { events.error = fn; }, + getValidate() { return proof; }, + showCaptcha() { calls.shown++; }, verify() { calls.shown++; }, + destroy() { calls.destroyed++; events.close?.(); }, + }; + const init = (options, ready) => { calls.options = options; ready(widget); }; + return { events, calls, scope: { initGeetest4: init, initGeetest: init }, load: async () => {} }; +} + +test('captcha v4 opens the provider widget and preserves its proof plus the upstream challenge', async () => { + const proof = { captcha_id: 'TEST', lot_number: 'TEST-LOT', captcha_output: 'TEST-PROOF', pass_token: 'TEST-PASS', gen_time: '1' }; + const provider = fakeProvider(proof); + const pending = solveCaptcha({ geetestId: 'TEST', challenge: 'TEST-CHALLENGE', riskType: 'slide' }, provider); + await setImmediate(); + assert.equal(provider.calls.shown, 0, 'initialization does not submit a proof'); + provider.events.ready(); + assert.equal(provider.calls.shown, 1); + assert.deepEqual(provider.calls.options, { captchaId: 'TEST', riskType: 'slide', product: 'bind', language: 'zho' }); + provider.events.success(); + assert.deepEqual(await pending, { ...proof, challenge: 'TEST-CHALLENGE' }); + assert.equal(provider.calls.destroyed, 1); +}); + +test('captcha v3 preserves the three provider fields for the SMS retry', async () => { + const proof = { geetest_challenge: 'TEST-CH', geetest_validate: 'TEST-VALIDATE', geetest_seccode: 'TEST-SEC' }; + const provider = fakeProvider(proof); + const pending = solveCaptcha({ gt: 'TEST', challenge: 'TEST-CH', new_captcha: true }, provider); + await setImmediate(); + provider.events.ready(); + provider.events.success(); + assert.deepEqual(await pending, proof); + assert.equal(provider.calls.options.https, true); +}); + +test('captcha cancellation never yields a proof, including after a provider retry event', async () => { + const provider = fakeProvider({ captcha_output: 'UNUSED-TEST-PROOF' }); + const pending = solveCaptcha({ geetestId: 'TEST' }, provider); + await setImmediate(); + provider.events.error({ error_code: 'error_01' }); + provider.events.close(); + provider.events.success(); + assert.equal(await pending, null); + assert.equal(provider.calls.destroyed, 1); +}); + +test('unsupported or failed captchas are rejected so the UI can offer retry', async () => { + await assert.rejects(() => solveCaptcha({}), /captcha_type/); + await assert.rejects(() => solveCaptcha({ geetestId: 'TEST' }, { load: async () => { throw new Error('captcha_load'); } }), /captcha_load/); + const provider = fakeProvider(null); + const pending = solveCaptcha({ geetestId: 'TEST' }, provider); + const rejected = assert.rejects(pending, /captcha_validate/); + await setImmediate(); + provider.events.success(); + await rejected; +}); diff --git a/test/skland/client.test.js b/test/skland/client.test.js new file mode 100644 index 00000000..70f2c810 --- /dev/null +++ b/test/skland/client.test.js @@ -0,0 +1,324 @@ +// The Skland login chains (server/skland/client.js): 扫码 / 验证码 / 密码, the 人机验证 hand-off, and the two +// credential-refresh paths. +// +// Every case runs against a stub upstream whose answers are the shapes the community documentation records (the +// 3mua.cn write-up and TNXG/skland-api), so the chain's *order* and the *fields on the wire* are pinned without +// touching the network. The stub records each call, and the assertions read that record — a chain that skipped a step +// or sent the wrong body fails here rather than in production. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createSklandClient, SklandError, SklandCaptchaError, SklandNeedDeviceError } from '../../server/skland/client.js'; + +/** + * A stub upstream. `overrides` replaces individual answers by endpoint key. + * Keys: genScan, scanStatus, scanToken, sendCode, phoneToken, passwordToken, grant, cred, binding, refresh. + */ +function stubUpstream(overrides = {}) { + const calls = []; + const j = (o) => ({ status: 200, json: async () => o }); + const answers = { + genScan: () => j({ status: 0, msg: 'OK', data: { scanId: 'SCAN1', scanUrl: 'hypergryph://scan_login?scanId=SCAN1' } }), + scanStatus: () => j({ status: 0, msg: 'OK', data: { scanCode: 'SCANCODE1' } }), + scanToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-SCAN', hgId: 'HG1', deviceToken: 'DEV1' } }), + sendCode: () => j({ status: 0, msg: 'OK' }), + phoneToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-CODE', hgId: 'HG1', deviceToken: 'DEV1' } }), + passwordToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-PW' } }), + grant: ({ body }) => j({ status: 0, msg: 'OK', data: body.appCode === 'be36d44aa36bfb5b' + ? { token: 'BINDINGTOKEN' } : { uid: 'U1', code: 'GRANTCODE' } }), + cred: () => j({ code: 0, data: { cred: 'CRED', token: 'CREDTOKEN' } }), + binding: () => j({ code: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士' }] }] } }), + accountBinding: () => j({ status: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士', isDefault: true }] }] } }), + refresh: () => j({ code: 0, data: { token: 'CREDTOKEN2' } }), + }; + const routes = [ + ['gen_scan/login', 'genScan'], ['scan_status', 'scanStatus'], ['token_by_scan_code', 'scanToken'], + ['send_phone_code', 'sendCode'], ['token_by_phone_code', 'phoneToken'], + ['token_by_phone_password', 'passwordToken'], ['oauth2/v2/grant', 'grant'], + ['generate_cred_by_code', 'cred'], ['auth/refresh', 'refresh'], ['player/binding', 'binding'], + ['account/binding/v1/binding_list', 'accountBinding'], + ]; + const fetchImpl = async (url, init = {}) => { + const key = routes.find(([frag]) => url.includes(frag))?.[1]; + const answer = overrides[key] ?? answers[key]; + calls.push({ + key, url, method: init.method || 'GET', + body: init.body ? JSON.parse(init.body) : null, + headers: init.headers || {}, + }); + if (!answer) throw new Error(`stub: no route for ${url}`); + return answer(calls.at(-1)); + }; + return { fetchImpl, calls, byKey: (key) => calls.filter((c) => c.key === key) }; +} + +test('扫码登录: gen_scan → scan_status → token_by_scan_code → grant → cred', async () => { + const up = stubUpstream(); + const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1 }); + const r = await client.loginByScan(); + + assert.equal(r.scanId, 'SCAN1'); + assert.equal(r.scanUrl, 'hypergryph://scan_login?scanId=SCAN1', 'the QR encodes scanUrl, not the scanId'); + assert.equal(r.token, 'T-SCAN'); + assert.equal(r.deviceToken, 'DEV1'); + assert.equal(r.cred, 'CRED'); + assert.equal(r.credToken, 'CREDTOKEN'); + + assert.deepEqual(up.calls.map((c) => c.key), [ + 'genScan', 'scanStatus', 'scanToken', 'grant', 'cred', + ]); + assert.deepEqual(up.byKey('genScan')[0].body, { appCode: '4ca99fa6b56cc2ba' }); + assert.deepEqual(up.byKey('scanToken')[0].body, { appCode: '4ca99fa6b56cc2ba', from: 0, scanCode: 'SCANCODE1' }); + assert.equal(up.byKey('grant')[0].body.deviceToken, 'DEV1', 'the grant carries the scan deviceToken'); +}); + +test('扫码登录: the QR session carries the device headers the upstream requires', async () => { + const up = stubUpstream(); + const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1 }); + await client.loginByScan(); + const headers = up.byKey('scanToken')[0].headers; + assert.match(headers['X-DeviceId'], /^[0-9a-f]{10}$/); + assert.equal(headers['X-DeviceType'], '2'); + assert.ok(headers['X-DeviceModel']); +}); + +test('扫码登录: 未扫码 (100) and 已扫码待确认 (101) keep polling until confirmed', async () => { + const pending = [ + { status: 100, msg: '未扫码' }, + { status: 101, msg: '已扫码待确认' }, + { status: 101, msg: '已扫码待确认' }, + ]; + const up = stubUpstream({ + scanStatus: () => ({ status: 200, json: async () => pending.shift() + ?? { status: 0, msg: 'OK', data: { scanCode: 'SCANCODE1' } } }), + }); + const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1 }); + const onWait = []; + const r = await client.loginByScan({ onWait: (s) => onWait.push(s) }); + assert.equal(r.token, 'T-SCAN'); + assert.equal(up.byKey('scanStatus').length, 4, 'polled through phone confirmation'); + assert.deepEqual(onWait, ['waiting', 'waiting', 'waiting']); + assert.equal(up.byKey('scanToken').length, 1, 'the confirmed code is exchanged once'); +}); + +test('扫码登录: terminal and unknown statuses still fail instead of polling indefinitely', async () => { + for (const status of [102, 999]) { + const up = stubUpstream({ + scanStatus: () => ({ status: 200, json: async () => ({ status, msg: '扫码已失效' }) }), + }); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + await assert.rejects(() => client.loginByScan(), (e) => { + assert.ok(e instanceof SklandError); + assert.equal(e.code, status); + assert.equal(e.step, 'checkScan'); + return true; + }); + assert.equal(up.byKey('scanStatus').length, 1); + assert.equal(up.byKey('scanToken').length, 0); + } +}); + +test('扫码登录: a timeout stops the poll with a readable error', async () => { + const up = stubUpstream({ scanStatus: () => ({ status: 200, json: async () => ({ status: 100, msg: '未扫码' }) }) }); + const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1, pollTimeoutMs: 0 }); + await assert.rejects(() => client.loginByScan(), (e) => { + assert.ok(e instanceof SklandError); + assert.equal(e.step, 'pollScan'); + assert.match(e.message, /超时/); + return true; + }); +}); + +test('验证码登录: send_phone_code(type 2, LOGIN_OR_REGISTER) → token_by_phone_code → grant → cred', async () => { + const up = stubUpstream(); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + assert.deepEqual(await client.sendPhoneCode('13800000000'), { ok: true }); + const r = await client.loginByPhoneCode('13800000000', '123456'); + + assert.equal(r.token, 'T-CODE'); + assert.equal(r.hgId, 'HG1'); + assert.equal(r.deviceToken, 'DEV1'); + assert.equal(r.cred, 'CRED'); + assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2 }); + assert.deepEqual(up.byKey('phoneToken')[0].body, { appCode: '4ca99fa6b56cc2ba', code: '123456', phone: '13800000000' }); + assert.equal(up.byKey('grant')[0].body.deviceToken, 'DEV1'); + assert.equal(up.byKey('phoneToken')[0].headers['X-DeviceType'], '2'); +}); + +test('验证码登录: 需要人机验证 surfaces the geetest challenge', async () => { + const captcha = { gt: 'GT', challenge: 'CH', new_captcha: true }; + const up = stubUpstream({ + sendCode: () => ({ status: 200, json: async () => ({ status: 1, msg: '需要人机验证', type: 'A', data: { captcha } }) }), + }); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + await assert.rejects(() => client.sendPhoneCode('13800000000'), (e) => { + assert.ok(e instanceof SklandCaptchaError); + assert.equal(e.captcha.gt, 'GT'); + assert.equal(e.captcha.challenge, 'CH'); + assert.equal(e.captcha.new_captcha, true); + return true; + }); +}); + +test('验证码登录: a solved captcha is echoed into the resend', async () => { + const up = stubUpstream(); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + const solved = { geetest_challenge: 'a', geetest_validate: 'b', geetest_seccode: 'c|d' }; + await client.sendPhoneCode('13800000000', solved); + assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2, captcha: solved }); +}); + +test('验证码登录: 发送频率太高 (status 102) is a readable error', async () => { + const up = stubUpstream({ + sendCode: () => ({ status: 200, json: async () => ({ status: 102, msg: '发送频率太高,请稍后再试', type: 'C1' }) }), + }); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + await assert.rejects(() => client.sendPhoneCode('13800000000'), (e) => { + assert.ok(e instanceof SklandError); + assert.equal(e.code, 102); + assert.match(e.message, /频率太高/); + return true; + }); +}); + +test('密码登录: password → account-service grant → official Arknights bindings without Skland', async () => { + const up = stubUpstream(); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + const r = await client.loginByPhonePassword('13800000000', 'pw'); + assert.equal(r.token, 'T-PW'); + assert.equal(r.binding.list[0].bindingList[0].nickName, '博士'); + assert.deepEqual(up.byKey('passwordToken')[0].body, { phone: '13800000000', password: 'pw' }); + assert.deepEqual(up.byKey('grant')[0].body, { token: 'T-PW', appCode: 'be36d44aa36bfb5b', type: 1 }); + const url = new URL(up.byKey('accountBinding')[0].url); + assert.equal(url.origin, 'https://binding-api-account-prod.hypergryph.com'); + assert.equal(url.pathname, '/account/binding/v1/binding_list'); + assert.equal(url.searchParams.get('token'), 'BINDINGTOKEN'); + assert.equal(url.searchParams.get('appCode'), 'arknights'); + assert.equal(up.byKey('cred').length, 0); + assert.equal(up.byKey('binding').length, 0); +}); + +test('密码登录: without a deviceToken the chain still attempts grant and succeeds', async () => { + const up = stubUpstream(); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + const r = await client.loginByPhonePassword('13800000000', 'pw'); + assert.equal(r.binding.list[0].bindingList.length, 1); + assert.deepEqual(up.byKey('grant')[0].body, { token: 'T-PW', appCode: 'be36d44aa36bfb5b', type: 1 }); +}); + +test('密码登录: only an upstream status 109 requires device verification', async () => { + const up = stubUpstream({ + grant: () => ({ status: 200, json: async () => ({ status: 109, msg: '需要设备验证' }) }), + }); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + await assert.rejects(() => client.loginByPhonePassword('13800000000', 'pw'), (e) => { + assert.ok(e instanceof SklandNeedDeviceError); + assert.equal(e.code, 109); + return true; + }); + assert.equal(up.byKey('grant').length, 1, 'the upstream decides whether verification is needed'); + assert.equal(up.byKey('cred').length, 0, 'a rejected grant never advances to credentials'); + assert.equal(up.byKey('accountBinding').length, 0); +}); + +test('account bindings: a grant missing its service token never queries with an undefined token', async () => { + const up = stubUpstream({ grant: () => ({ status: 200, json: async () => ({ status: 0, data: {} }) }) }); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + await assert.rejects(() => client.getAccountBinding('T'), (e) => e.code === 'no_token'); + assert.equal(up.byKey('accountBinding').length, 0); +}); + +test('account bindings: upstream errors and malformed lists do not become empty successful bindings', async () => { + for (const answer of [{ status: 3, msg: '登录已过期' }, { status: 0, data: {} }]) { + const up = stubUpstream({ accountBinding: () => ({ status: 200, json: async () => answer }) }); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + await assert.rejects(() => client.getAccountBinding('T'), SklandError); + } +}); + +test('grant: the deviceToken is sent only when the login path produced one', async () => { + const up = stubUpstream(); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + await client.getGrantCode('T', null); + assert.ok(!('deviceToken' in up.byKey('grant')[0].body)); + await client.getGrantCode('T', 'DEV'); + assert.equal(up.byKey('grant')[1].body.deviceToken, 'DEV'); +}); + +test('getBinding: the signed request carries cred, dId, timestamp, vName and a signature', async () => { + const up = stubUpstream(); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + const r = await client.getBinding({ cred: 'CRED', credToken: 'CREDTOKEN', dId: 'DEV' }); + assert.equal(r.binding.list[0].appCode, 'arknights'); + const call = up.byKey('binding')[0]; + assert.equal(call.method, 'GET'); + assert.equal(call.headers.cred, 'CRED'); + assert.equal(call.headers.dId, 'DEV'); + assert.equal(call.headers.platform, '3'); + assert.equal(call.headers.vName, '1.0.0'); + assert.match(call.headers.sign, /^[0-9a-f]{32}$/, 'sign = md5(hmacSha256(...))'); + assert.ok(Number(call.headers.timestamp) > 0); +}); + +test('getBinding: code 10000 refreshes the cred_token once and retries', async () => { + let first = true; + const up = stubUpstream({ + binding: () => { + if (first) { first = false; return { status: 200, json: async () => ({ code: 10000, message: 'cred_token 过期' }) }; } + return { status: 200, json: async () => ({ code: 0, data: { list: [] } }) }; + }, + }); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + const r = await client.getBinding({ cred: 'CRED', credToken: 'OLD', dId: 'DEV' }); + assert.equal(r.credToken, 'CREDTOKEN2', 'the refreshed cred_token is returned to the caller'); + assert.equal(up.byKey('refresh').length, 1); + assert.equal(up.byKey('binding').length, 2, 'retried once'); +}); + +test('getBinding: code 10002 rebuilds the cred from the stored access_token', async () => { + let first = true; + const up = stubUpstream({ + binding: () => { + if (first) { first = false; return { status: 200, json: async () => ({ code: 10002, message: 'cred 过期' }) }; } + return { status: 200, json: async () => ({ code: 0, data: { list: [] } }) }; + }, + }); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + const r = await client.getBinding({ cred: 'OLD', credToken: 'OLD', dId: 'DEV', accessToken: 'AT', deviceToken: 'DEV1' }); + assert.equal(r.cred, 'CRED', 'the rebuilt cred is returned to the caller'); + assert.equal(r.credToken, 'CREDTOKEN'); + assert.equal(up.byKey('grant').length, 1); + assert.equal(up.byKey('cred').length, 1); +}); + +test('getBinding: 用户未登录 reads as an expired session', async () => { + const up = stubUpstream({ + binding: () => ({ status: 200, json: async () => ({ code: 10002, message: '用户未登录' }) }), + }); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + await assert.rejects(() => client.getBinding({ cred: 'C', credToken: 'CT', dId: 'D' }), (e) => { + assert.ok(e instanceof SklandError); + assert.match(e.message, /过期|未登录/); + return true; + }); +}); + +test('an upstream failure carries the step that failed', async () => { + const up = stubUpstream({ genScan: () => ({ status: 200, json: async () => ({ status: 1, msg: '系统繁忙' }) }) }); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + await assert.rejects(() => client.genScan(), (e) => { + assert.ok(e instanceof SklandError); + assert.equal(e.message, '系统繁忙'); + assert.equal(e.step, '获取扫码会话'); + return true; + }); +}); + +test('a non-JSON answer is reported as such instead of throwing a parse error', async () => { + const up = stubUpstream({ genScan: () => ({ status: 502, json: async () => { throw new Error('bad json'); } }) }); + const client = createSklandClient({ fetchImpl: up.fetchImpl }); + await assert.rejects(() => client.genScan(), (e) => { + assert.ok(e instanceof SklandError); + assert.match(e.message, /响应不是 JSON/); + return true; + }); +}); diff --git a/test/skland/crypto.test.js b/test/skland/crypto.test.js new file mode 100644 index 00000000..7af9ad61 --- /dev/null +++ b/test/skland/crypto.test.js @@ -0,0 +1,92 @@ +// The Skland request signature (server/skland/crypto.js): the composition of the signed header set. +// +// The upstream's algorithm has two independent references in the community (the 3mua.cn write-up on the account login +// surface and TNXG/skland-api's crypto module). This file pins the *composition* — which bytes go into the HMAC, and +// in what order — because that is the part a refactor can silently break. The digest itself is checked against +// node:crypto's own HMAC-SHA256 and MD5, so a change of either primitive shows up here. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHmac, createHash } from 'node:crypto'; +import { signedHeaders, hmacSha256Hex, md5, compactJson, makeDeviceId, deviceHeaders } from '../../server/skland/crypto.js'; + +const ref = (key, msg) => createHash('md5').update(createHmac('sha256', key).update(msg, 'utf8').digest('hex'), 'utf8').digest('hex'); + +test('compactJson: no whitespace, insertion order kept (the upstream signs the exact body bytes)', () => { + assert.equal(compactJson({ b: 1, a: 2 }), '{"b":1,"a":2}'); + assert.equal(compactJson({ gameId: 1, uid: '123' }), '{"gameId":1,"uid":"123"}'); + assert.equal(compactJson({}), '{}'); +}); + +test('hmacSha256Hex / md5 are the plain primitives', () => { + assert.equal(hmacSha256Hex('key', 'msg'), createHmac('sha256', 'key').update('msg').digest('hex')); + assert.equal(md5('abc'), '900150983cd24fb0d6963f7d28e17f72'); +}); + +test('a GET sign input is path + query + timestamp + compact(header_ca)', () => { + const { signInput, headers } = signedHeaders({ + cred: 'CRED', credToken: 'CTOK', dId: 'DEV', method: 'GET', path: '/api/v1/game/player/binding', + query: 'a=1', timestampSeconds: 1700000000, + }); + const ca = '{"platform":"3","timestamp":"1700000000","dId":"DEV","vName":"1.0.0"}'; + assert.equal(signInput, `/api/v1/game/player/bindinga=11700000000${ca}`); + assert.equal(headers.sign, ref('CTOK', signInput)); + assert.equal(headers.cred, 'CRED'); + assert.equal(headers.platform, '3'); + assert.equal(headers.vName, '1.0.0'); + assert.equal(headers.dId, 'DEV'); +}); + +test('a GET without a query signs the bare path', () => { + const { signInput } = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'GET', path: '/p', timestampSeconds: 1 }); + assert.equal(signInput, `/p1{"platform":"3","timestamp":"1","dId":"d","vName":"1.0.0"}`); +}); + +test('a POST sign input is path + compact(body) + timestamp + compact(header_ca)', () => { + const body = { gameId: 1, uid: '123' }; + const { signInput, headers } = signedHeaders({ + cred: 'CRED', credToken: 'CTOK', dId: 'DEV', method: 'POST', path: '/api/v1/game/attendance', + body, timestampSeconds: 1700000000, + }); + const ca = '{"platform":"3","timestamp":"1700000000","dId":"DEV","vName":"1.0.0"}'; + assert.equal(signInput, `/api/v1/game/attendance{"gameId":1,"uid":"123"}1700000000${ca}`); + assert.equal(headers.sign, ref('CTOK', signInput)); +}); + +test('a POST without a body signs "{}"', () => { + const { signInput } = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'POST', path: '/p', timestampSeconds: 1 }); + assert.equal(signInput, `/p{}1{"platform":"3","timestamp":"1","dId":"d","vName":"1.0.0"}`); +}); + +test('the signature is stable for equal inputs and moves with any single input', () => { + const base = { cred: 'c', credToken: 'k', dId: 'd', method: 'POST', path: '/p', body: { x: 1 }, timestampSeconds: 5 }; + const a = signedHeaders(base).headers.sign; + assert.equal(signedHeaders({ ...base }).headers.sign, a); + assert.notEqual(signedHeaders({ ...base, credToken: 'k2' }).headers.sign, a); + assert.notEqual(signedHeaders({ ...base, dId: 'd2' }).headers.sign, a); + assert.notEqual(signedHeaders({ ...base, timestampSeconds: 6 }).headers.sign, a); + assert.notEqual(signedHeaders({ ...base, body: { x: 2 } }).headers.sign, a); + assert.notEqual(signedHeaders({ ...base, path: '/q' }).headers.sign, a); + assert.notEqual(signedHeaders({ ...base, method: 'GET', query: '' }).headers.sign, a); +}); + +test('the mobile client identity headers the upstream expects', () => { + const h = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'GET', path: '/p', timestampSeconds: 1 }).headers; + assert.match(h['User-Agent'], /^Skland\//); + assert.equal(h['X-Requested-With'], 'com.hypergryph.skland'); + assert.equal(h['Accept-Encoding'], 'gzip'); +}); + +test('makeDeviceId: stable shape, a fresh value each call', () => { + const a = makeDeviceId(); + const b = makeDeviceId(); + assert.match(a, /^B[0-9a-f]{32}$/); + assert.notEqual(a, b); +}); + +test('deviceHeaders: the login device headers, cached for the process', () => { + const h = deviceHeaders(); + assert.match(h['X-DeviceId'], /^[0-9a-f]{10}$/); + assert.equal(h['X-DeviceType'], '2'); + assert.ok(h['X-DeviceModel'].length > 0); + assert.equal(deviceHeaders(), h, 'one device per process (the upstream sees a stable device)'); +}); diff --git a/test/skland/docker.test.js b/test/skland/docker.test.js new file mode 100644 index 00000000..ccb910ce --- /dev/null +++ b/test/skland/docker.test.js @@ -0,0 +1,147 @@ +// The Docker packaging of the opt-in Skland account binding: docker-compose.yml, the Dockerfile's runtime stage and +// .gitignore form one contract — the credentials live in one SQLite file on a named volume that the unprivileged +// `node` user can write, and that file is never committed and never reachable over HTTP. +// +// No Docker daemon is needed (and CI has none): the files are read as text and the facts that make `docker compose up +// -d` work are asserted. docker-compose.yml is parsed with a few lines of indentation-based reading rather than a YAML +// dependency, so the assertions rest on the structure (which service, which key) instead of one loose regex. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const read = (rel) => readFileSync(join(ROOT, rel), 'utf8'); +const linesOf = (text) => text.split(/\r?\n/).map((l) => l.replace(/\r$/, '')); + +/** The indentation of a line (a YAML block is defined by it). */ +const indentOf = (l) => l.length - l.replace(/^[ \t]*/, '').length; + +/** + * The lines of the block under `key:` at exactly `indent` — comments and blank lines dropped (they are prose, not + * structure), a dedent ends it. Returns [] when the key is absent. Enough YAML to read this one file honestly. + * @param {string[]} lines + * @param {string} key + * @param {number} indent + */ +function block(lines, key, indent) { + const head = new RegExp(`^ {${indent}}${key}:`); + const out = []; + let inside = false; + for (const l of lines) { + if (!l.trim() || l.trim().startsWith('#')) continue; + if (!inside) { if (indentOf(l) === indent && head.test(l)) inside = true; continue; } + if (indentOf(l) <= indent) break; + out.push(l); + } + return out; +} + +/** The scalar of `key: value` inside a block, with surrounding quotes removed; null when unset. */ +function scalar(blockLines, key) { + for (const l of blockLines) { + const m = l.match(new RegExp(`^[ \\t]*${key}:[ \\t]*(.*)$`)); + if (m) return m[1].trim().replace(/^(["'])(.*)\1$/, '$2'); + } + return null; +} + +/** The `- item` entries of the list under `key:` inside a block (quotes removed). */ +function items(blockLines, key, indent) { + return block(blockLines, key, indent) + .map((l) => l.match(/^[ \t]*-[ \t]*(.*)$/)?.[1] ?? '') + .filter(Boolean) + .map((v) => v.trim().replace(/^(["'])(.*)\1$/, '$2')); +} + +const compose = linesOf(read('docker-compose.yml')); +const services = block(compose, 'services', 0); +const service = block(services, 'stronghold', 2); + +test('docker-compose.yml: one stronghold service, built locally, tagged and restarted', () => { + assert.deepEqual(services.filter((l) => indentOf(l) === 2 && /:$/.test(l.trim())).map((l) => l.trim()), + ['stronghold:'], 'exactly one service'); + const build = block(service, 'build', 4); + assert.equal(scalar(build, 'context'), '.', 'builds the repository Dockerfile'); + assert.equal(scalar(service, 'image'), 'stronghold-protocol', 'tags the image'); + assert.equal(scalar(service, 'restart'), 'unless-stopped'); + // the art is opt-in: the compose file must not force a 550 MB download unless the operator uncomments it + assert.equal(scalar(build, 'args'), null, 'FETCH_ASSETS stays the caller\'s choice'); +}); + +test('docker-compose.yml: publishes 3000:3000', () => { + const ports = items(service, 'ports', 4); + assert.ok(ports.includes('3000:3000'), `publishes 3000:3000 (got ${JSON.stringify(ports)})`); +}); + +test('docker-compose.yml: SP_SKLAND=1 and SP_SKLAND_DB inside the mounted named volume', () => { + const env = block(service, 'environment', 4); + assert.equal(scalar(env, 'SP_SKLAND'), '1', 'the Skland module is on'); + const db = scalar(env, 'SP_SKLAND_DB'); + assert.ok(db, 'SP_SKLAND_DB is set'); + + // the database must sit inside a mount, and that mount must be the named volume (survives down / rebuilds) + const mounts = items(service, 'volumes', 4); + const named = mounts.find((m) => m.startsWith('skland-data:')); + assert.ok(named, `the named volume skland-data is mounted (got ${JSON.stringify(mounts)})`); + const target = named.slice(named.indexOf(':') + 1); + assert.ok(target.startsWith('/'), `the volume has an absolute container path (${target})`); + assert.ok(db.startsWith(`${target}/`), `SP_SKLAND_DB ${db} is inside the mounted volume ${target}`); + // the volume is declared, so `docker compose up` creates it rather than failing on an unknown name + assert.ok(block(compose, 'volumes', 0).some((l) => indentOf(l) === 2 && l.trim().startsWith('skland-data')), + 'the top-level volumes: section declares skland-data'); +}); + +test('docker-compose.yml: the credential file is outside every static mount (/public, /data)', () => { + const db = scalar(block(service, 'environment', 4), 'SP_SKLAND_DB'); + for (const dir of ['/app/public', '/app/data', '/app/shared']) { + assert.ok(!db.startsWith(`${dir}/`), `${db} is not under ${dir}: the static handler would serve it over HTTP`); + } +}); + +test('Dockerfile: /app/skland-data exists and belongs to node before USER node', () => { + const df = linesOf(read('Dockerfile')); + const user = df.findIndex((l) => /^USER[ \t]+node[ \t]*$/.test(l)); + assert.ok(user > 0, 'the runtime stage still runs as USER node'); + const mkdir = df.findIndex((l) => /^RUN[^\n]*mkdir -p[^\n]*\/app\/skland-data\b/.test(l)); + assert.ok(mkdir >= 0, 'the database directory is created'); + assert.ok(mkdir < user, `mkdir (line ${mkdir + 1}) precedes USER node (line ${user + 1})`); + assert.match(df[mkdir], /chown[ \t]+node:node[ \t]+\/app\/skland-data\b/, 'owned by node, so node can write it'); + // a VOLUME instruction would hand `docker run` an anonymous volume the host cannot find; the mount is explicit + assert.ok(!df.some((l) => /^VOLUME\b/.test(l) && l.includes('/app/skland-data')), + 'no anonymous VOLUME for the credential directory'); + assert.match(read('Dockerfile'), /SP_SKLAND_DB/, 'the top comment documents the data path'); +}); + +test('the compose volume target is the directory the image prepares', () => { + const named = items(service, 'volumes', 4).find((m) => m.startsWith('skland-data:')); + const target = named.slice(named.indexOf(':') + 1); + const df = linesOf(read('Dockerfile')); + const mkdir = df.find((l) => l.includes('mkdir -p') && l.includes(target)); + assert.ok(mkdir, `the image creates ${target} before USER node, the directory compose mounts`); + assert.match(mkdir, new RegExp(`mkdir -p[ \\t]+${target.replace(/\//g, '\\/')}\\b`)); +}); + +test('.gitignore: the runtime database and its SQLite side files are never committed', () => { + const lines = linesOf(read('.gitignore')).map((l) => l.trim()).filter((l) => l && !l.startsWith('#')); + for (const entry of ['data/skland.db', 'data/skland.db-wal', 'data/skland.db-shm', 'skland-data/']) { + assert.ok(lines.includes(entry), `.gitignore lists ${entry}`); + } +}); + +test('docs/DEPLOY.md: the Docker section documents SP_SKLAND, SP_SKLAND_DB, the volume and the secret warning', () => { + const doc = read('docs/DEPLOY.md'); + const start = doc.indexOf('## 3. Docker'); + const end = doc.indexOf('## 4. ', start); + assert.ok(start >= 0 && end > start, 'the Docker section is still there'); + const docker = doc.slice(start, end); + assert.match(docker, /`SP_SKLAND`/, 'names SP_SKLAND'); + assert.match(docker, /`SP_SKLAND_DB`/, 'names SP_SKLAND_DB'); + assert.match(docker, /docker compose up -d/, 'the one-command path'); + assert.match(docker, /skland-data/, 'names the volume'); + assert.match(docker, /密钥|凭据/, 'warns that the database holds credentials'); + assert.match(docker, /chmod|权限/, 'tells the operator to restrict the file mode'); + assert.match(docker, /备份/, 'tells the operator to back it up carefully'); + assert.match(docker, /HTTP/, 'warns against exposing it over HTTP'); +}); diff --git a/test/skland/entry.browser.test.js b/test/skland/entry.browser.test.js new file mode 100644 index 00000000..e175cfd2 --- /dev/null +++ b/test/skland/entry.browser.test.js @@ -0,0 +1,211 @@ +// Real title and modal, stub account provider. No real account, SMS, authorization or CAPTCHA is exercised. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { startServer } from '../../server/index.js'; +import { SklandCaptchaError } from '../../server/skland/client.js'; + +const chrome = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe'; +const enabled = process.env.SP_E2E === '1' && fs.existsSync(chrome); + +test('title requires binding, reads the nickname, keeps channel choice and handles CAPTCHA, SMS and restored entry', { + skip: enabled ? false : 'set SP_E2E=1 and CHROME_PATH (needs Chrome)', timeout: 60000, +}, async () => { + const binding = { list: [{ appCode: 'arknights', bindingList: [ + { uid: '123', nickName: '官服博士', channelName: '官服', gameId: 1 }, + { uid: '123', nickName: 'B服博士', channelName: 'B服', gameId: 2, isDefault: true }, + ] }] }; + let polls = 0, scans = 0, passwordProof, smsSends = 0; + const client = { + genScan: async () => { scans++; return { scanId: `SCAN${scans}`, scanUrl: `hypergryph://scan_login?scanId=SCAN${scans}` }; }, + checkScan: async () => (++polls > 1 ? { state: 'confirmed', scanCode: 'CODE' } : { state: 'waiting', awaitingConfirmation: true }), + tokenByScanCode: async () => ({ token: 'SCAN-TOKEN' }), + getGrantCode: async () => 'GRANT', + getCred: async () => ({ cred: 'CRED', credToken: 'SIGN' }), + getBinding: async () => ({ binding, cred: 'CRED', credToken: 'SIGN' }), + sendPhoneCode: async () => { smsSends++; }, + tokenByPhoneCode: async () => ({ token: 'SMS-TOKEN' }), + tokenByPhonePassword: async (_phone, _password, captcha) => { + if (!captcha) throw new SklandCaptchaError({ geetestId: 'FAKE', challenge: 'ORIGINAL' }, 'password'); + passwordProof = captcha; return { token: 'PASSWORD-TOKEN' }; + }, + getAccountBinding: async () => binding, + }; + const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', client } }); + let browser; + try { + const puppeteer = (await import('puppeteer-core')).default; + browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-first-run', '--no-sandbox'] }); + let page; + const errors = []; + const newPage = async () => { + const tab = await browser.newPage(); + await tab.setViewport({ width: 1440, height: 1000 }); + tab.on('pageerror', (error) => errors.push(error.message)); + await tab.setRequestInterception(true); + tab.on('request', (request) => { + if (/^https?:/.test(request.url()) && !request.url().startsWith(server.url)) request.abort(); + else request.continue(); + }); + return tab; + }; + page = await newPage(); + await page.goto(server.url); + await page.waitForSelector('[data-testid="binding-start"]'); + const clickText = async (text) => { + await page.waitForFunction((value) => [...document.querySelectorAll('.sp-binding button')] + .some((button) => button.textContent.trim() === value && !button.disabled), {}, text); + await page.evaluate((value) => [...document.querySelectorAll('.sp-binding button')] + .find((button) => button.textContent.trim() === value).click(), text); + }; + const open = async () => { + await page.click(await page.$('[data-testid="account-binding-entry"]') ? '[data-testid="account-binding-entry"]' : '[data-testid="binding-start"]'); + await page.waitForSelector('.sp-binding'); + await page.waitForFunction(() => window.__SP__?.net.status === 'online' && !document.querySelector('.sp-binding .btn[aria-busy="true"]')); + }; + const callsign = () => page.evaluate(() => document.querySelector('[data-testid="bound-callsign"]')?.textContent || ''); + const expectName = async (name) => { + try { + await page.waitForFunction((value) => (document.querySelector('[data-testid="bound-callsign"]')?.textContent || '') === value, { timeout: 12000 }, name); + assert.equal(await page.$('.title-login input'), null, 'the callsign is never an input'); + assert.equal(!!await page.$('.sp-binding-callsign'), !!name, 'the callsign area only exists after binding'); + if (name) assert.ok(await page.$('.sp-binding-callsign__icon'), 'the nickname keeps its person icon'); + } catch (error) { + console.error('Callsign state:', await page.evaluate(() => ({ + callsign: document.querySelector('[data-testid="bound-callsign"]')?.textContent, + action: document.querySelector('[data-testid="binding-start"]')?.textContent, + playerId: window.__SP__?.store.get().me.playerId, connection: window.__SP__?.net.status, + alerts: [...document.querySelectorAll('[role="alert"]')].map((element) => element.textContent), + })), 'page errors:', errors); + throw error; + } + }; + const expectAction = async (text) => page.waitForFunction((value) => document.querySelector('[data-testid="binding-start"]')?.textContent.trim() === value, {}, text); + await expectAction('绑定账号'); + await expectName(''); + assert.equal(await page.$('.title-login label'), null); + // Old manual callsigns and a cached entered flag must not bypass the server binding check. + await page.evaluate(() => { localStorage.setItem('sp.name', '旧的手填代号'); sessionStorage.setItem('sp.entered', '1'); }); + await page.reload(); + await expectAction('绑定账号'); + assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false); + await open(); + assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false); + await page.waitForSelector('.sp-binding__qr'); + assert.equal(scans, 1, 'opening the scan tab automatically fetches a QR'); + const firstQr = await page.$eval('.sp-binding__qr', (image) => image.src); + await clickText('重新获取二维码'); + await page.waitForFunction((previous) => { + const image = document.querySelector('.sp-binding__qr'); return image && image.src !== previous; + }, {}, firstQr); + assert.equal(scans, 2, 'manual QR refresh remains available'); + await page.waitForFunction(() => document.querySelector('.sp-binding__scan [role="status"]')?.textContent.includes('已扫码')); + await expectName('B服博士'); + await expectAction('开始'); + assert.ok(polls >= 2); + await clickText('官服博士官服 · UID 123'); + await expectName('官服博士'); + await clickText('刷新角色'); + await expectName('官服博士'); + await clickText('完成'); + await open(); + assert.equal(await callsign(), '官服博士'); + await clickText('解绑账号'); + await expectName(''); + await expectAction('绑定账号'); + await clickText('密码'); + await page.type('.sp-binding input[type="tel"]', '13800000000'); + await page.type('.sp-binding input[type="password"]', 'FAKE-PASSWORD'); + await clickText('登录并绑定'); + await page.waitForSelector('.sp-binding__captcha'); + await page.evaluate(() => { + window.initGeetest4 = (_options, callback) => { + let success; + callback({ + onSuccess(fn) { success = fn; }, onError() {}, onClose() {}, + onReady(fn) { setTimeout(fn, 0); }, showCaptcha() { success(); }, + getValidate() { return { captcha_id: 'FAKE', lot_number: 'TEST' }; }, destroy() {}, + }); + }; + }); + await clickText('开始人机验证'); + await expectName('B服博士'); + assert.equal(passwordProof.challenge, 'ORIGINAL'); + assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false, 'binding never presses Start'); + fs.mkdirSync('.cache/skland-preview', { recursive: true }); + await new Promise((resolve) => setTimeout(resolve, 350)); + await page.screenshot({ path: '.cache/skland-preview/bound.png' }); + await clickText('解绑账号'); + await clickText('验证码'); + await clickText('发送验证码'); + await page.waitForFunction(() => document.querySelector('.sp-binding__notice')?.textContent.includes('验证码已发送')); + assert.equal(smsSends, 1); + await page.type('.sp-binding input[autocomplete="one-time-code"]', '123456'); + await clickText('登录并绑定'); + await expectName('B服博士'); + await clickText('完成'); + await new Promise((resolve) => setTimeout(resolve, 350)); + await page.screenshot({ path: '.cache/skland-preview/entry-bound.png' }); + await page.reload(); + await expectName('B服博士'); + const accountId = () => page.evaluate(async () => { + const response = await fetch('/skland/session/status', { headers: { authorization: 'Bearer ' + window.__SP__.store.get().me.token } }); + return (await response.json()).gamePlayerId; + }); + const previousId = await accountId(); + await page.close(); page = await newPage(); await page.goto(server.url); + await expectName('B服博士'); await expectAction('开始'); + assert.equal(await accountId(), previousId, 'a fresh tab restores the JWT-owned role without logging in or entering'); + assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false); + await open(); + // Keep the existing target alive while checking the landscape layout; device emulation can reload the app and + // would test browser boot behavior rather than this feature's responsive CSS. + await page.setViewport({ width: 844, height: 390 }); + await new Promise((resolve) => setTimeout(resolve, 350)); + await page.screenshot({ path: '.cache/skland-preview/mobile.png' }); + const fits = await page.$eval('.sp-binding', (element) => { + const rect = element.getBoundingClientRect(); + const overlay = element.parentElement?.getBoundingClientRect(); + return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, height: rect.height, + overlay: overlay && { top: overlay.top, bottom: overlay.bottom, height: overlay.height }, viewport: [innerWidth, innerHeight] }; + }); + assert.equal(fits.left >= 0 && fits.right <= fits.viewport[0] && fits.top >= 0 && fits.bottom <= fits.viewport[1], true, + `the modal fits a landscape phone: ${JSON.stringify(fits)}`); + await clickText('解绑账号'); + await expectName(''); + await expectAction('绑定账号'); + await clickText('完成'); + await page.click('[data-testid="binding-start"]'); + await page.waitForSelector('.sp-binding'); + assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false); + // A login without a named role still cannot start. + const namedRoles = binding.list[0].bindingList; + binding.list[0].bindingList = []; + await clickText('验证码'); + await page.type('.sp-binding input[type="tel"]', '13800000000'); + await page.type('.sp-binding input[autocomplete="one-time-code"]', '123456'); + await clickText('登录并绑定'); + await page.waitForSelector('.sp-binding__roles'); + await expectAction('绑定账号'); + await expectName(''); + binding.list[0].bindingList = namedRoles; + await clickText('刷新角色'); + await expectName('B服博士'); + await clickText('完成'); + await page.click('[data-testid="binding-start"]'); + await page.waitForSelector('.lobby-screen'); + assert.equal(await page.evaluate(() => window.__SP__.store.get().me.name), 'B服博士'); + await page.reload(); + await page.waitForSelector('.lobby-screen'); + // Removing the binding on the server invalidates the cached entry on the next reload. + await page.evaluate(async () => { + await fetch('/skland/session/unbind', { method: 'POST', headers: { + authorization: `Bearer ${window.__SP__.store.get().me.token}`, 'content-type': 'application/json', + }, body: '{}' }); + }); + await page.reload(); + await expectAction('绑定账号'); + assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false); + assert.deepEqual(errors, []); + } finally { await browser?.close(); await server.close(); } +}); diff --git a/test/skland/http.test.js b/test/skland/http.test.js new file mode 100644 index 00000000..60e7095b --- /dev/null +++ b/test/skland/http.test.js @@ -0,0 +1,415 @@ +// The /skland/* HTTP surface and its per-player orchestration (server/skland/service.js, server/skland/http.js). +// +// The server is started for real (startServer with `skland: { storeFile: ':memory:', fetchImpl }`), so the wiring — +// routes.js handing /skland/* to the module before its GET/HEAD gate, the JSON bodies, the store — is what is under +// test; only the upstream HTTP is a stub. Each case drives one login path the way the panel would. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer } from '../../server/index.js'; + +/** The stub upstream (the shapes the community documentation records). */ +function stubUpstream(overrides = {}) { + const calls = []; + const j = (o) => ({ status: 200, json: async () => o }); + const answers = { + genScan: () => j({ status: 0, msg: 'OK', data: { scanId: 'SCAN1', scanUrl: 'hypergryph://scan_login?scanId=SCAN1' } }), + scanStatus: () => j({ status: 100, msg: '未扫码' }), + scanToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-SCAN', hgId: 'HG1', deviceToken: 'DEV1' } }), + sendCode: () => j({ status: 0, msg: 'OK' }), + phoneToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-CODE', hgId: 'HG1', deviceToken: 'DEV1' } }), + passwordToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-PW' } }), + grant: ({ body }) => j({ status: 0, msg: 'OK', data: body.appCode === 'be36d44aa36bfb5b' + ? { token: 'BINDINGTOKEN' } : { uid: 'U1', code: 'GRANTCODE' } }), + cred: () => j({ code: 0, data: { cred: 'CRED', token: 'CREDTOKEN' } }), + binding: () => j({ code: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士', channelName: '官服', gameId: 1 }] }] } }), + accountBinding: () => j({ status: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士', channelName: '官服', isDefault: true }] }] } }), + refresh: () => j({ code: 0, data: { token: 'CREDTOKEN2' } }), + }; + const routes = [ + ['gen_scan/login', 'genScan'], ['scan_status', 'scanStatus'], ['token_by_scan_code', 'scanToken'], + ['send_phone_code', 'sendCode'], ['token_by_phone_code', 'phoneToken'], + ['token_by_phone_password', 'passwordToken'], ['oauth2/v2/grant', 'grant'], + ['generate_cred_by_code', 'cred'], ['auth/refresh', 'refresh'], ['player/binding', 'binding'], + ['account/binding/v1/binding_list', 'accountBinding'], + ]; + const fetchImpl = async (url, init = {}) => { + const key = routes.find(([frag]) => url.includes(frag))?.[1]; + calls.push({ key, url, method: init.method || 'GET', body: init.body ? JSON.parse(init.body) : null }); + const answer = overrides[key] ?? answers[key]; + if (!answer) throw new Error(`stub: no route for ${url}`); + return answer(calls.at(-1)); + }; + return { fetchImpl, calls, byKey: (key) => calls.filter((c) => c.key === key) }; +} + +/** Start a server with the stubbed Skland module. */ +async function withServer(overrides, fn) { + const up = stubUpstream(overrides); + const srv = await startServer({ + port: 0, host: '127.0.0.1', quiet: true, + skland: { storeFile: ':memory:', fetchImpl: up.fetchImpl, diagnosticKey: 'test-admin-only' }, + }); + const post = (path, body) => fetch(srv.url + path, { + method: 'POST', headers: { 'content-type': 'application/json', 'x-sp-diagnostic-key': 'test-admin-only' }, body: JSON.stringify(body), + }).then(async (r) => ({ status: r.status, body: await r.json() })); + const get = (path) => fetch(srv.url + path, { headers: { 'x-sp-diagnostic-key': 'test-admin-only' } }).then(async (r) => ({ status: r.status, body: await r.json() })); + try { await fn({ srv, post, get, up }); } finally { await srv.close(); } +} + +test('the server exposes the module when skland is configured', async () => { + await withServer({}, async ({ srv }) => { + assert.ok(srv.skland, 'startServer returns the service'); + }); +}); + +test('the module is off by default, and /skland/* still answers one uniform JSON 404', async () => { + const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + try { + assert.equal(srv.skland, null, 'no account store on an unconfigured server'); + const got = await fetch(srv.url + '/skland/status?player=p1'); + assert.equal(got.status, 404, 'GET /skland/* is not routed to a store'); + assert.equal((await got.json()).ok, false, 'the off-surface is JSON, not the HTML error page'); + + // A POST must not reach routes.js's GET/HEAD gate and answer "405 method not allowed" for a feature that is + // simply off — the panel would show a cryptic method error instead of "not enabled". + const posted = await fetch(srv.url + '/skland/scan/start', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ player: 'p1' }), + }); + assert.equal(posted.status, 404, 'POST is 404 too, not 405'); + assert.match((await posted.json()).error, /SP_SKLAND|未启用/); + } finally { await srv.close(); } +}); + +test('GET /skland/status: an unknown player reads as unbound', async () => { + await withServer({}, async ({ get }) => { + const { status, body } = await get('/skland/status?player=p1'); + assert.equal(status, 200); + assert.deepEqual(body, { playerId: 'p1', status: 'unbound', bound: false, nickname: null, hgId: null, roles: [] }); + }); +}); + +test('GET /skland/status without a player is a JSON 400', async () => { + await withServer({}, async ({ get }) => { + const { status, body } = await get('/skland/status'); + assert.equal(status, 400); + assert.equal(body.ok, false, 'a JSON client must never be handed the HTML error page'); + assert.match(body.error, /player/); + }); +}); + +test('扫码: start → unscanned → awaiting phone confirmation → confirmed binds the same session', async () => { + const pending = [ + { status: 100, msg: '未扫码' }, + { status: 101, msg: '已扫码待确认' }, + { status: 101, msg: '已扫码待确认' }, + ]; + await withServer({ + scanStatus: () => ({ status: 200, json: async () => pending.shift() + ?? { status: 0, msg: 'OK', data: { scanCode: 'SC1' } } }), + binding: () => ({ status: 200, json: async () => ({ code: 0, data: { list: [{ appCode: 'arknights', bindingList: [ + { uid: '123', nickName: '博士', channelName: '官服', gameId: 1 }, + { uid: '456', nickName: 'B服博士', channelName: 'B服', gameId: 2 }, + ] }] } }) }), + }, async ({ post, get, up }) => { + const start = await post('/skland/scan/start', { player: 'p1' }); + assert.equal(start.status, 200); + assert.equal(start.body.ok, true); + assert.equal(start.body.scanUrl, 'hypergryph://scan_login?scanId=SCAN1'); + + for (let i = 0; i < 3; i++) { + const waiting = await post('/skland/scan/poll', { player: 'p1' }); + assert.equal(waiting.body.ok, true); + assert.equal(waiting.body.state, 'waiting'); + assert.equal(waiting.body.awaitingConfirmation, i > 0, 'status 101 is preserved for the longer QR refresh window'); + assert.equal(waiting.body.scanUrl, start.body.scanUrl, 'the QR URL survives every pending poll'); + const scanning = await get('/skland/status?player=p1'); + assert.equal(scanning.body.status, 'scanning'); + assert.equal(scanning.body.bound, false); + assert.equal(up.byKey('scanToken').length, 0, 'no token exchange before confirmation'); + } + + const done = await post('/skland/scan/poll', { player: 'p1' }); + assert.equal(done.body.state, 'confirmed'); + assert.equal(done.body.status, 'bound'); + + const status = await get('/skland/status?player=p1'); + assert.equal(status.body.bound, true); + assert.equal(status.body.nickname, '博士'); + assert.deepEqual(status.body.roles, [ + { uid: '123', nickName: '博士', channelName: '官服', isDefault: false, gameId: 1 }, + { uid: '456', nickName: 'B服博士', channelName: 'B服', isDefault: false, gameId: 2 }, + ]); + assert.equal(up.byKey('scanToken')[0].body.scanCode, 'SC1'); + assert.equal(up.byKey('scanToken').length, 1); + assert.equal(up.byKey('grant').length, 1); + assert.equal(up.byKey('accountBinding').length, 0, 'QR login keeps the Skland binding provider'); + assert.equal(up.byKey('genScan').length, 1, 'no replacement QR is needed after status 101'); + }); +}); + +test('扫码: a poll without a session answers state none', async () => { + await withServer({}, async ({ post }) => { + const r = await post('/skland/scan/poll', { player: 'never-started' }); + assert.equal(r.body.ok, true); + assert.equal(r.body.state, 'none'); + }); +}); + +test('扫码: a failing poll still names a state, so the client never reads `undefined`', async () => { + // The panel showed "未知状态 · Unknown state: undefined" when the upstream died mid-scan: the answer was a bare + // { ok: false } with no `state`, and a caller that only switches on `state` fell through to its unknown branch. + await withServer({ scanStatus: () => { throw new Error('upstream down'); } }, async ({ post }) => { + await post('/skland/scan/start', { player: 'p1' }); + const { body } = await post('/skland/scan/poll', { player: 'p1' }); + assert.equal(body.ok, false); + assert.equal(body.state, 'error', 'every poll answer carries a terminal state'); + assert.match(body.error, /upstream down/); + }); +}); + +test('验证码: send → login binds the player', async () => { + await withServer({}, async ({ post, get, up }) => { + const sent = await post('/skland/phone/send', { player: 'p1', phone: '13800000000' }); + assert.equal(sent.body.ok, true); + assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2 }); + + const login = await post('/skland/phone/login', { player: 'p1', phone: '13800000000', code: '123456' }); + assert.equal(login.body.ok, true); + assert.equal(login.body.nickname, '博士'); + assert.equal(login.body.roles.length, 1); + + const status = await get('/skland/status?player=p1'); + assert.equal(status.body.bound, true); + assert.equal(status.body.hgId, 'HG1', 'the hgId from the login is kept'); + assert.equal(up.byKey('grant').length, 1, 'exactly one grant per login'); + assert.equal(up.byKey('grant')[0].body.deviceToken, 'DEV1'); + }); +}); + +test('验证码: 需要人机验证 is passed through with the geetest challenge', async () => { + await withServer({ + sendCode: () => ({ status: 200, json: async () => ({ status: 1, msg: '需要人机验证', data: { captcha: { gt: 'GT', challenge: 'CH', new_captcha: true } } }) }), + }, async ({ post }) => { + const r = await post('/skland/phone/send', { player: 'p1', phone: '13800000000' }); + assert.equal(r.body.ok, false); + assert.equal(r.body.needCaptcha, true); + assert.deepEqual(r.body.captcha, { gt: 'GT', challenge: 'CH', new_captcha: true }); + }); +}); + +test('验证码: a solved captcha travels back to the upstream', async () => { + await withServer({}, async ({ post, up }) => { + const solved = { geetest_challenge: 'a', geetest_validate: 'b', geetest_seccode: 'c|d' }; + await post('/skland/phone/send', { player: 'p1', phone: '13800000000', captcha: solved }); + assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2, captcha: solved }); + }); +}); + +test('密码: a fresh player can bind without a previous 验证码/扫码 login', async () => { + await withServer({}, async ({ post, get, up }) => { + const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' }); + assert.equal(r.body.ok, true); + assert.equal(r.body.nickname, '博士'); + assert.equal((await get('/skland/status?player=p1')).body.bound, true); + assert.equal(up.byKey('phoneToken').length, 0); + assert.equal(up.byKey('scanToken').length, 0); + assert.deepEqual(up.byKey('grant')[0].body, { token: 'T-PW', appCode: 'be36d44aa36bfb5b', type: 1 }); + assert.equal(r.body.roles[0].isDefault, true, 'official isDefault is preserved'); + assert.equal(up.byKey('cred').length, 0, 'no Skland credentials are acquired'); + assert.equal(up.byKey('binding').length, 0); + }); +}); + +test('密码: upstream device verification (109) is explained after attempting login', async () => { + await withServer({ + grant: () => ({ status: 200, json: async () => ({ status: 109, msg: '需要设备验证' }) }), + }, async ({ post, up, srv }) => { + const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' }); + assert.equal(r.body.ok, false); + assert.equal(r.body.needDevice, true); + assert.match(r.body.error, /验证码|扫码/); + assert.equal(up.byKey('passwordToken').length, 1); + assert.equal(up.byKey('grant').length, 1); + assert.equal(up.byKey('cred').length, 0); + assert.equal(srv.skland._store.get('p1').access_token, null, 'failed login credentials are not persisted'); + }); +}); + +test('密码: an incorrect password stays a password error without requiring a prior login', async () => { + await withServer({ + passwordToken: () => ({ status: 200, json: async () => ({ status: 100, msg: '手机号或密码错误' }) }), + }, async ({ post, up }) => { + const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'wrong' }); + assert.equal(r.body.ok, false); + assert.equal(r.body.needDevice, undefined); + assert.equal(r.body.error, '手机号或密码错误'); + assert.equal(up.byKey('passwordToken').length, 1); + assert.equal(up.byKey('grant').length, 0); + }); +}); + +test('密码: interactive captcha proofs resume the password and account-grant steps separately', async () => { + const challenge = { geetestId: 'TEST-ID', challenge: 'TEST-CHALLENGE', riskType: 'slide' }; + const passwordProof = { captcha_output: 'TEST-PASSWORD-PROOF', challenge: 'TEST-CHALLENGE' }; + const grantProof = { captcha_output: 'TEST-GRANT-PROOF', challenge: 'TEST-CHALLENGE' }; + const answer = (data) => ({ status: 200, json: async () => data }); + await withServer({ + passwordToken: ({ body }) => answer(body.captcha + ? { status: 0, data: { token: 'T-AFTER-CAPTCHA' } } : { status: 1, data: { captcha: challenge } }), + grant: ({ body }) => answer(body.captcha + ? { status: 0, data: { token: 'BINDINGTOKEN' } } : { status: 1, data: { captcha: challenge } }), + }, async ({ post, up, srv }) => { + const login = { player: 'p1', phone: '13800000000', password: 'pw' }; + const first = await post('/skland/password/login', login); + assert.equal(first.body.needCaptcha, true); + assert.equal(first.body.captchaStep, 'password'); + assert.deepEqual(first.body.captcha, challenge); + assert.equal(up.byKey('grant').length, 0); + const second = await post('/skland/password/login', { ...login, captcha: passwordProof, captchaStep: 'password' }); + assert.equal(second.body.needCaptcha, true); + assert.equal(second.body.captchaStep, 'accountGrant'); + assert.deepEqual(up.byKey('passwordToken')[1].body.captcha, passwordProof); + assert.equal(srv.skland._store.get('p1'), null, 'pending credentials are not persisted'); + const third = await post('/skland/password/login', { ...login, captcha: grantProof, captchaStep: 'accountGrant' }); + assert.equal(third.body.ok, true); + assert.equal(third.body.roles.length, 1); + assert.equal(up.byKey('passwordToken').length, 2, 'the successful password exchange is not repeated'); + assert.deepEqual(up.byKey('grant').map((c) => c.body.token), ['T-AFTER-CAPTCHA', 'T-AFTER-CAPTCHA']); + assert.deepEqual(up.byKey('grant')[1].body.captcha, grantProof); + }); +}); + +test('密码: unbind discards a pending grant captcha and cannot resurrect its login', async () => { + await withServer({ + grant: () => ({ status: 200, json: async () => ({ status: 1, data: { captcha: { geetestId: 'TEST-ID' } } }) }), + }, async ({ post, up }) => { + const login = { player: 'p1', phone: '13800000000', password: 'pw' }; + assert.equal((await post('/skland/password/login', login)).body.captchaStep, 'accountGrant'); + await post('/skland/unbind', { player: 'p1' }); + const retry = await post('/skland/password/login', { ...login, captcha: { captcha_output: 'TEST' }, captchaStep: 'accountGrant' }); + assert.equal(retry.body.ok, false); + assert.match(retry.body.error, /过期/); + assert.equal(up.byKey('grant').length, 1); + assert.equal(up.byKey('accountBinding').length, 0); + }); +}); + +test('密码: a previous Skland binding switches to official account lookup without reusing its deviceToken', async () => { + await withServer({}, async ({ post, up, srv }) => { + await post('/skland/phone/login', { player: 'p1', phone: '13800000000', code: '123456' }); + const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' }); + assert.equal(r.body.ok, true); + assert.equal(r.body.nickname, '博士'); + assert.deepEqual(up.byKey('passwordToken')[0].body, { phone: '13800000000', password: 'pw' }, + 'the password endpoint takes phone + password only'); + const grant = up.byKey('grant').at(-1); + assert.equal(grant.body.deviceToken, undefined, 'official account lookup does not need the Skland deviceToken'); + assert.equal(grant.body.appCode, 'be36d44aa36bfb5b'); + assert.equal(grant.body.type, 1); + assert.equal(grant.body.token, 'T-PW'); + const row = srv.skland._store.get('p1'); + assert.equal(row.binding_source, 'hypergryph'); + assert.equal(row.cred, null); + assert.equal(row.cred_token, null); + assert.equal(row.device_token, null); + }); +}); + +test('密码: role refresh uses official account bindings and rebind via SMS restores the Skland source', async () => { + await withServer({}, async ({ post, get, up, srv }) => { + await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' }); + assert.equal((await get('/skland/roles?player=p1')).body.roles.length, 1); + assert.equal(up.byKey('accountBinding').length, 1, 'cached lookup does not call upstream'); + const refreshed = await get('/skland/roles?player=p1&refresh=1'); + assert.equal(refreshed.body.ok, true); + assert.equal(refreshed.body.roles[0].isDefault, true); + assert.equal(up.byKey('accountBinding').length, 2); + assert.equal(up.byKey('cred').length, 0); + await post('/skland/phone/login', { player: 'p1', phone: '13800000000', code: '123456' }); + assert.equal(srv.skland._store.get('p1').binding_source, 'skland'); + await get('/skland/roles?player=p1&refresh=1'); + assert.equal(up.byKey('binding').length, 2); + assert.equal(up.byKey('accountBinding').length, 2); + }); +}); + +test('解绑 clears the credentials and the status reads unbound again', async () => { + await withServer({}, async ({ post, get }) => { + await post('/skland/phone/login', { player: 'p1', phone: '138', code: '1' }); + assert.equal((await get('/skland/status?player=p1')).body.bound, true); + assert.equal((await post('/skland/unbind', { player: 'p1' })).body.ok, true); + const after = await get('/skland/status?player=p1'); + assert.equal(after.body.bound, false); + assert.equal(after.body.status, 'unbound'); + assert.deepEqual(after.body.roles, []); + }); +}); + +test('players are isolated: one binding never leaks into another', async () => { + await withServer({}, async ({ post, get }) => { + await post('/skland/phone/login', { player: 'p1', phone: '138', code: '1' }); + assert.equal((await get('/skland/status?player=p1')).body.bound, true); + assert.equal((await get('/skland/status?player=p2')).body.bound, false, 'p2 stays unbound'); + }); +}); + +test('GET /skland/roles returns the cached binding, and ?refresh=1 re-queries upstream', async () => { + await withServer({}, async ({ post, get, up }) => { + await post('/skland/phone/login', { player: 'p1', phone: '138', code: '1' }); + const cached = await get('/skland/roles?player=p1'); + assert.equal(cached.body.ok, true); + assert.equal(cached.body.roles.length, 1); + assert.equal(up.byKey('binding').length, 1, 'the cached read does not hit the upstream'); + + const refreshed = await get('/skland/roles?player=p1&refresh=1'); + assert.equal(refreshed.body.ok, true); + assert.equal(up.byKey('binding').length, 2, 'refresh re-queries'); + }); +}); + +test('a failed 验证码 login records the error on the status', async () => { + await withServer({ + phoneToken: () => ({ status: 200, json: async () => ({ status: 1, msg: '验证码错误' }) }), + }, async ({ post, get }) => { + const r = await post('/skland/phone/login', { player: 'p1', phone: '138', code: '000000' }); + assert.equal(r.body.ok, false); + assert.equal(r.body.error, '验证码错误'); + const status = await get('/skland/status?player=p1'); + assert.equal(status.body.status, 'error'); + assert.equal(status.body.error, '验证码错误'); + assert.equal(status.body.bound, false); + }); +}); + +test('an unknown /skland endpoint is a JSON 404 and a bad method a JSON 405', async () => { + await withServer({}, async ({ post, srv }) => { + const unknown = await post('/skland/nope', { player: 'p1' }); + assert.equal(unknown.status, 404); + assert.equal(unknown.body.ok, false); + + const del = await fetch(srv.url + '/skland/status?player=p1', { method: 'DELETE', headers: { 'x-sp-diagnostic-key': 'test-admin-only' } }); + assert.equal(del.status, 405); + const body = await del.json(); + assert.equal(body.ok, false, 'the method error is JSON too'); + }); +}); + +test('a malformed JSON body is a 400, not a crash', async () => { + await withServer({}, async ({ srv }) => { + const res = await fetch(srv.url + '/skland/phone/login', { + method: 'POST', headers: { 'content-type': 'application/json', 'x-sp-diagnostic-key': 'test-admin-only' }, body: '{not json', + }); + assert.equal(res.status, 400); + assert.equal((await res.json()).ok, false); + }); +}); + +test('the rest of the server is unaffected: /healthz and / still answer', async () => { + await withServer({}, async ({ srv }) => { + const health = await fetch(srv.url + '/healthz'); + assert.equal(health.status, 200); + assert.equal((await health.json()).ok, true); + const home = await fetch(srv.url + '/'); + assert.ok([200, 404].includes(home.status), 'the static mount still owns /'); + }); +}); diff --git a/test/skland/panel.test.js b/test/skland/panel.test.js new file mode 100644 index 00000000..694c97ac --- /dev/null +++ b/test/skland/panel.test.js @@ -0,0 +1,115 @@ +// test/skland/panel.test.js — public/skland.html, the standalone Skland binding panel (scan / SMS / password). +// +// The panel is one self-contained HTML file with an inline module script (no build step, no browser test), so this +// test reads it as TEXT and pins only the contract facts that would silently rot: the endpoints it calls, that the QR +// encodes the scanUrl deep link (NOT the scan session id), that captcha challenges are handled, that a 404 reads as +// "the module is off", and that the 2 s polling loop always gets cleared. +// String checks like these are deliberately few and each one names the failure it prevents. +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const HTML_PATH = path.join(ROOT, 'public', 'skland.html'); + +describe('the Skland binding panel (public/skland.html)', () => { + assert.ok(existsSync(HTML_PATH), 'the panel is a real file the static server can serve'); + const html = readFileSync(HTML_PATH, 'utf8'); + + test('is one whole document: a doctype, an inline module script and a closed html tag', () => { + // A stray `` or an unclosed tag is exactly the kind of damage a text-only test can catch. + assert.match(html, /^/i); + assert.equal((html.match(//g) || []).length, 'every script opens and closes once'); + assert.match(html, /]*\bsrc=)[^>]*type="module"/i, 'the panel logic is an inline module (no build step)'); + assert.match(html, /<\/body>\s*<\/html>\s*$/, 'the document ends where it should'); + }); + + test('every endpoint the panel needs is referenced', () => { + // Each is a distinct login/status step of server/skland/http.js; dropping one breaks that path silently. + for (const endpoint of [ + '/skland/status', '/skland/scan/start', '/skland/scan/poll', '/skland/phone/send', + '/skland/phone/login', '/skland/password/login', '/skland/unbind', '/skland/roles', + ]) { + assert.ok(html.includes(endpoint), `the panel calls ${endpoint}`); + } + assert.match(html, /\/skland\/roles\?player=.*refresh=1/, 'a bound refresh re-queries the roles'); + }); + + test('the QR code encodes scanUrl — never the scanId', () => { + // The upstream deep link is `hypergryph://scan_login?…` (server/skland/service.js startScan). Encoding the opaque + // scanId would render a QR that no phone can act on, and nothing else in the flow would fail loudly. + assert.ok(html.includes('/vendor/qrcode.js'), 'the vendored library is loaded as a classic script'); + const addData = html.match(/qr\.addData\(\s*([^)]*)\)/); + assert.ok(addData, 'the generator is fed one payload'); + assert.match(addData[1], /scanUrl/, 'the payload is the scanUrl deep link'); + assert.doesNotMatch(addData[1], /scanId/, 'never the scan session id'); + assert.match(html, /renderQr\(\s*r\.scanUrl\s*\)/, 'the value comes from the scan/start answer'); + assert.match(html, /typeof qrcode !== 'function'/, 'a missing vendor file is detected…'); + assert.match(html, /createSvgTag|createImgTag/, '…and the present library renders a tag'); + assert.match(html, /textContent\s*=\s*String\(scanUrl\)|value\s*=\s*String\(scanUrl\)/, '…with the deep link as selectable text'); + }); + + test('the QR frame step is named exactly as the server sends it', () => { + // service.js pollScan answers state ∈ none|waiting|confirmed|expired; 'none' and 'expired' mean start over. + for (const state of ["'waiting'", "'confirmed'", "'expired'", "'none'"]) { + assert.ok(html.includes(`state === ${state}`), `the poll handles state ${state}`); + } + }); + + test('a 404 (or the 405 a POST gets) on /skland/* reads as "the module is off", not as a broken page', () => { + // The module is opt-in (SP_SKLAND=1). With it off /skland/* is not routed at all: a GET falls through to the + // static server's 404 page, but a POST hits routes.js's GET/HEAD gate first and gets a 405 HTML page. Both are + // the bilingual error page, while an enabled module always answers JSON (errors included) — so the panel keys on + // the HTML content type as well, and only a plain 404 check would show a confusing "method not allowed". + assert.match(html, /res\.status === 404/); + assert.match(html, /text\/html/, 'an HTML answer is recognised as the wrong server…'); + assert.match(html, /该功能未启用\s*·\s*the module is off/); + assert.ok(html.includes('id="module-off"'), 'the notice has a place to render'); + assert.match(html, /setModuleOff\(\)/, 'the notice is raised, not just logged'); + }); + + test('needCaptcha is handled', () => { + // needCaptcha: send → geetest challenge, then resend with the solved fields (service.js sendPhoneCode). + assert.ok(html.includes('needCaptcha')); + assert.match(html, /needCaptcha[\s\S]{0,200}renderCaptcha\(/, 'the challenge data is displayed'); + assert.ok(html.includes('/js/skland/captcha.js'), 'the shared interactive widget supplies the proof'); + assert.ok(html.includes('pw-captcha-box'), 'password login has its own challenge control'); + }); + + test('a poll answer is never read as `undefined`: ok:false is honoured and `state` is always named', () => { + // The panel once showed "未知状态 · Unknown state: undefined" because the server answered a failed poll with a + // bare { ok: false } and pollOnce only switched on `state`. Both halves are pinned here. + assert.match(html, /r\.ok === false/, 'pollOnce checks ok:false before it reads state'); + assert.match(html, /r\.state === 'error'/, "the server's terminal error state is handled"); + assert.match(html, /未知状态[\s\S]{0,160}brief\(JSON\.stringify\(r\)\)/, 'the unknown branch shows the real body'); + }); + + test('the poll runs about every 2 s and its timer is always cleared', () => { + // A leaked interval keeps asking the server after the tab is hidden or the section is left (service.js deletes the + // scan session on the first non-waiting answer, so a leaked poll can only ever report state 'none' — noise). + const ms = html.match(/POLL_MS\s*=\s*(\d+)/); + assert.ok(ms, 'the interval is a named constant'); + assert.equal(Number(ms[1]), 2000, 'the documented ~2 s'); + assert.match(html, /setInterval\([\s\S]{0,80}?POLL_MS\s*\)/, 'the timer uses that constant'); + assert.match(html, /clearInterval\(pollTimer\)/, 'the single cleanup path clears the interval'); + assert.match(html, /document\.hidden\)\s*return/, 'the poll never starts on a hidden tab'); + assert.match(html, /document\.hidden\)\s*pollStop\(\)/, 'a tab that goes hidden stops the poll'); + assert.ok(html.includes("addEventListener('visibilitychange'"), 'visibility changes are watched'); + assert.ok(html.includes("addEventListener('pagehide'"), 'leaving the page stops the poll too'); + }); + + test('the identity shown is the ?player= / localStorage one, labelled as a placeholder', () => { + assert.ok(html.includes('URLSearchParams'), 'the player id is read from the URL'); + assert.ok(html.includes('sp.skland.player'), 'and kept in localStorage under a clear key'); + assert.match(html, /placeholder/i, 'the page says the id is a placeholder until the session provides one'); + }); + + test('no external network calls and no inline secrets', () => { + // The page must work on an offline LAN box: only same-origin /vendor and /skland URLs are allowed. No tokens, + // passwords or phone numbers are baked in either. + assert.doesNotMatch(html, /https?:\/\//, 'no absolute http(s) URL anywhere'); + assert.doesNotMatch(html, /\b(?:token|secret|password)\s*[:=]\s*['"][A-Za-z0-9]{8,}['"]/i, 'no hardcoded credential'); + }); +}); diff --git a/test/skland/recovery.browser.test.js b/test/skland/recovery.browser.test.js new file mode 100644 index 00000000..8350d5ad --- /dev/null +++ b/test/skland/recovery.browser.test.js @@ -0,0 +1,167 @@ +// Two isolated browser contexts exercise real title/login/recovery UI against an entirely stubbed provider. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { startServer } from '../../server/index.js'; + +const chrome = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe'; +const enabled = process.env.SP_E2E === '1' && fs.existsSync(chrome); + +test('a separately logged-in device restores the match without local saves, and stale or duplicated tabs do not take it back', + { skip: !enabled, timeout: 90_000 }, async () => { + const binding = { list: [{ appCode: 'arknights', bindingList: [ + { uid: '123', channelName: '官服', nickName: '跨设备博士', isDefault: true }, + ] }] }; + const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, assetIndex: false, + skland: { storeFile: ':memory:', client: { + genScan: async () => ({ scanId: 'TEST', scanUrl: 'hypergryph://scan_login?scanId=TEST' }), + checkScan: async () => ({ state: 'waiting' }), + tokenByPhonePassword: async () => ({ token: 'FAKE' }), + getAccountBinding: async () => binding, + } } }); + let browser; + const errors = []; + try { + const puppeteer = (await import('puppeteer-core')).default; + browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-first-run', '--no-sandbox'] }); + const firstContext = await browser.createBrowserContext(), secondContext = await browser.createBrowserContext(); + const pageIn = async (context) => { + const page = await context.newPage(); + await page.setViewport({ width: 1440, height: 1000 }); + page.on('pageerror', (error) => errors.push(error.message)); + await page.setRequestInterception(true); + page.on('request', (request) => { + if (/^https?:/.test(request.url()) && !request.url().startsWith(server.url)) request.abort(); + else request.continue(); + }); + return page; + }; + const clickDialog = async (page, label) => { + // QR loading can disable the tabs between separate readiness and click calls. Check/click atomically. + await page.waitForFunction((text) => { + const button = [...document.querySelectorAll('.sp-binding button')] + .find((element) => element.textContent.trim() === text && !element.disabled); + if (!button) return false; + button.click(); return true; + }, {}, label); + }; + const login = async (page) => { + await page.goto(server.url); + await page.waitForSelector('[data-testid="binding-start"]'); + await page.click('[data-testid="binding-start"]'); + await clickDialog(page, '密码'); + await page.waitForSelector('.sp-binding input[type="tel"]:not([disabled])'); + await page.type('.sp-binding input[type="tel"]', '13800000000'); + await page.type('.sp-binding input[type="password"]', 'FAKE-PASSWORD'); + await clickDialog(page, '登录并绑定'); + await page.waitForFunction(() => document.querySelector('[data-testid="bound-callsign"]')?.textContent === '跨设备博士'); + await clickDialog(page, '完成'); + }; + const a = await pageIn(firstContext), b = await pageIn(secondContext); + await login(a); + await a.click('[data-testid="resume-account-match"]'); + await a.waitForFunction(() => document.querySelector('.sp-account-resume [role="status"]')?.textContent === '当前角色没有可恢复的对局。'); + await a.keyboard.press('Escape'); + await a.waitForSelector('.sp-account-resume', { hidden: true }); + await a.click('[data-testid="binding-start"]'); + await a.waitForSelector('.lobby-screen'); + await a.evaluate(async () => { + await window.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' }); + await window.__SP__.net.request('room.start'); + }); + await a.waitForFunction(() => window.__SP__.store.get().match.private && window.__SP__.store.get().room?.inMatch); + const previous = await a.evaluate(() => { + const s = window.__SP__.store.get(); + return { code: s.room.code, playerId: s.me.playerId, token: s.me.token, private: s.match.private }; + }); + await login(b); + assert.equal(await b.evaluate(() => Object.keys(JSON.parse(localStorage.getItem('sp.matches') || '{}')).length), 0); + assert.equal(await b.evaluate(() => window.__SP__.store.get().match.private), null); + assert.equal(await b.evaluate(() => window.__SP__.store.get().session.entered), false); + assert.equal(await a.evaluate(() => window.__SP__.net.status), 'online'); + assert.equal(await b.$('[data-testid="resume-local-match"]'), null, 'account UI no longer offers a local-token recovery'); + await b.click('[data-testid="resume-account-match"]'); + await b.waitForSelector('[data-testid="confirm-account-resume"]'); + assert.match(await b.$eval('[data-testid="confirm-account-resume"]', (el) => el.textContent), new RegExp(previous.code)); + assert.equal(await a.evaluate(() => window.__SP__.net.status), 'online', 'opening the picker does not take over'); + fs.mkdirSync('.cache/skland-preview', { recursive: true }); + await b.$eval('.modal', (element) => Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished))); + await b.screenshot({ path: '.cache/skland-preview/account-recovery.png' }); + await b.setViewport({ width: 844, height: 390 }); + const fits = await b.$eval('.sp-account-resume', (element) => { + const rect = element.getBoundingClientRect(); + return rect.left >= 0 && rect.top >= 0 && rect.right <= innerWidth && rect.bottom <= innerHeight; + }); + assert.equal(fits, true, 'the recovery picker fits a landscape phone'); + const readable = await b.$eval('.sp-account-resume', (element) => ({ + font: parseFloat(getComputedStyle(element.querySelector('.modal__body')).fontSize), + hit: element.querySelector('[data-testid="confirm-account-resume"]').getBoundingClientRect().height, + })); + assert.ok(readable.font >= 14 && readable.hit >= 44, 'small-screen recovery stays readable and tappable'); + await b.screenshot({ path: '.cache/skland-preview/account-recovery-mobile.png' }); + await b.setViewport({ width: 1440, height: 1000 }); + await b.click('[data-testid="confirm-account-resume"]'); + await b.waitForFunction((code) => { + const s = window.__SP__.store.get(); + return s.session.entered && s.room?.code === code && !!s.match.private; + }, {}, previous.code); + const restored = await b.evaluate(() => window.__SP__.store.get()); + assert.equal(restored.me.playerId, previous.playerId); + assert.notEqual(restored.me.token, previous.token); + for (const key of ['funds', 'shop', 'hand', 'board']) assert.deepEqual(restored.match.private[key], previous.private[key]); + await a.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED' && window.__SP__.net.status === 'closed'); + const continueHere = async (page) => { + await page.waitForFunction(() => { + const button = [...document.querySelectorAll('.conn-banner button')] + .find((element) => element.textContent.trim() === '在此页面继续' && !element.disabled); + if (!button) return false; + button.click(); return true; + }); + await page.waitForFunction((code) => { + const s = window.__SP__.store.get(); + return window.__SP__.net.status === 'online' && s.session.entered && s.room?.code === code; + }, {}, previous.code); + }; + await continueHere(a); + await b.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED'); + await continueHere(b); + const heldToken = await b.evaluate(() => window.__SP__.store.get().me.token); + await a.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED'); + await a.reload(); + await a.waitForSelector('[data-testid="bound-callsign"]'); + assert.equal(await a.evaluate(() => window.__SP__.store.get().session.entered), false, 'an old saved entry cannot silently retake the role'); + assert.equal(await b.evaluate(() => window.__SP__.net.status), 'online'); + const duplicate = await pageIn(secondContext); + await duplicate.evaluateOnNewDocument((token) => { + sessionStorage.setItem('sp.token', token); sessionStorage.setItem('sp.entered', '1'); + }, heldToken); + await duplicate.goto(server.url); + await duplicate.waitForSelector('[data-testid="bound-callsign"]'); + assert.equal(await duplicate.evaluate(() => window.__SP__.store.get().session.entered), false); + assert.equal(await b.evaluate(() => window.__SP__.net.status), 'online', 'a duplicated tab does not take over either'); + // The match can end while the other device has its recovery dialog open. + await a.click('[data-testid="resume-account-match"]'); + await a.waitForSelector('[data-testid="confirm-account-resume"]'); + await b.evaluate(() => window.__SP__.net.request('g.leave')); + await a.click('[data-testid="confirm-account-resume"]'); + await a.waitForFunction(() => document.querySelector('.sp-account-resume [role="alert"]')?.textContent === '此对局已结束或过期,请刷新后重试。'); + assert.equal(await a.evaluate(() => window.__SP__.store.get().session.entered), false); + assert.equal(await a.$('[data-testid="confirm-account-resume"]'), null); + // An active page that loses its server session must not stay in an anonymous, unusable lobby. + const current = server.registry.byToken(heldToken); + server.registry.remove(current); + await b.evaluate(() => window.__SP__.net.reconnectNow()); + await b.waitForSelector('[data-testid="bound-callsign"]'); + assert.equal(await b.evaluate(() => window.__SP__.store.get().session.entered), false); + assert.deepEqual(errors, []); + } catch (error) { + const pages = browser ? await browser.pages() : []; + console.error('Recovery browser state:', await Promise.all(pages.map((page) => page.evaluate(() => ({ + status: window.__SP__?.net.status, entered: window.__SP__?.store.get().session.entered, + playerId: window.__SP__?.store.get().me.playerId, + callsign: document.querySelector('[data-testid="bound-callsign"]')?.textContent, + alerts: [...document.querySelectorAll('[role="alert"]')].map((element) => element.textContent), + })).catch(() => null))), 'page errors:', errors); + throw error; + } finally { await browser?.close(); await server.close(); } + }); diff --git a/test/skland/recovery.test.js b/test/skland/recovery.test.js new file mode 100644 index 00000000..dbb57c6b --- /dev/null +++ b/test/skland/recovery.test.js @@ -0,0 +1,195 @@ +// Independent devices have independent login cookies and no shared browser storage. No provider network calls. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { startServer } from '../../server/index.js'; +import { TestClient } from '../helpers/wsClient.js'; +import { roleKey } from '../../shared/accountIdentity.js'; +import { validateC2S } from '../../shared/protocol.js'; +import { accountPlayerId } from '../../server/skland/auth.js'; + +const role = (uid = '123', channelName = '官服', extra = {}) => ({ uid, channelName, nickName: '同名博士', ...extra }); + +async function fixture(t) { + const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, assetIndex: false, + skland: { storeFile: ':memory:' } }); + const clients = []; + t.after(async () => { await Promise.all(clients.map((client) => client.close())); await server.close(); }); + const connect = async (cookie, token) => { + const client = await TestClient.connect(server.url.replace('http:', 'ws:') + '/ws', { + wsOptions: { headers: cookie ? { cookie } : {} }, + }); + clients.push(client); + const welcome = await client.hello('Transport', token); + return { client, token: welcome.token, welcome }; + }; + const request = async (device, endpoint, body, headers = {}) => { + const response = await fetch(server.url + '/skland/session/' + endpoint, { + method: body === undefined ? 'GET' : 'POST', + headers: { authorization: 'Bearer ' + device.token, ...(device.cookie ? { cookie: device.cookie } : {}), + 'content-type': 'application/json', ...headers }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const cookie = response.headers.get('set-cookie'); + if (cookie) device.cookie = cookie.split(';')[0]; + return { status: response.status, body: await response.json(), cache: response.headers.get('cache-control') }; + }; + const device = async (roles = [role()]) => { + const d = await connect(); + server.skland._store.update('@session/' + d.welcome.playerId, { status: 'bound', nickname: roles[0].nickName, + binding: { list: [{ appCode: 'arknights', bindingList: roles }] } }); + const result = await request(d, 'status'); + assert.equal(result.status, 200); + d.state = result.body; + return d; + }; + const enter = async (d, id) => { + const result = await request(d, id ? 'resume' : 'enter', id ? { id } : {}); + assert.equal(result.status, 200, JSON.stringify(result.body)); + await d.client.close(); + Object.assign(d, await connect(d.cookie, result.body.token)); + assert.equal(d.welcome.playerId, result.body.gamePlayerId); + assert.notEqual(d.token, result.body.token, 'the accepted socket rotates the account token'); + return d; + }; + const start = async (d, mode = 'solo') => { + await enter(d); + assert.equal((await d.client.request({ t: 'room.create', mode, difficulty: 'NORMAL' })).t, 'ok'); + const state = await d.client.waitFor('room.state'); + assert.equal((await d.client.request({ t: 'room.start' })).t, 'ok'); + const room = server.lobby.rooms.get(state.code); + assert.ok(room.match, 'uses the real Match implementation'); + await d.client.waitFor('m.public'); + return room; + }; + const disconnect = async (d) => { + const session = server.registry.byToken(d.token); + const closed = once(session.ws, 'close'); + await d.client.close(); + await closed; + }; + return { server, connect, request, device, enter, start, disconnect }; +} + +test('a fresh device queries the selected role and explicitly resumes the same live match and private state', async (t) => { + const h = await fixture(t), a = await h.device(); + const room = await h.start(a), match = room.match, player = match.players.get(a.welcome.playerId); + player.funds = 37; + player.shop.freeRefreshes = 2; + const before = player.privateView(), oldToken = a.token; + const b = await h.device([role(123, '官服', { nickName: '已改名', gameId: null })]); + assert.notEqual(a.cookie, b.cookie, 'the device logs in independently, not by copying a JWT'); + assert.equal(a.state.gamePlayerId, b.state.gamePlayerId, 'nickname and optional metadata do not select another identity'); + assert.equal(a.client.isOpen, true, 'login alone does not take over the active device'); + assert.equal(b.client.log.some((frame) => frame.t === 'm.private'), false); + const result = await h.request(b, 'recovery'); + const item = result.body.recovery; + assert.equal(result.status, 200); + assert.match(result.cache, /no-store/); + assert.equal(item.code, room.code); assert.equal(item.inMatch, true); assert.equal(item.connected, true); + assert.equal(Object.hasOwn(item, 'token'), false, 'the read-only summary exposes no reconnect credential'); + assert.equal(a.client.isOpen, true, 'querying a match is not resuming it'); + await h.enter(b, item.id); + assert.equal((await a.client.closed).code, 4001, 'only successful explicit recovery replaces the old device'); + assert.equal(b.welcome.playerId, a.welcome.playerId); + assert.equal(room.match, match); + assert.equal((await b.client.waitFor('room.state')).code, room.code); + assert.deepEqual(await b.client.waitFor('m.private'), before); + assert.equal(h.server.registry.byToken(oldToken), null, 'the old device token was revoked'); + const stale = await h.connect(a.cookie, oldToken); + assert.notEqual(stale.welcome.playerId, b.welcome.playerId, 'an old device cannot silently take the role back'); + assert.equal(b.client.isOpen, true); + assert.equal((await stale.client.request({ t: 'g.ready' })).t, 'error'); +}); + +test('disconnected solo and co-op matches use the existing account-independent reconnect windows', async (t) => { + for (const mode of ['solo', 'coop']) await t.test(mode, async (t) => { + const h = await fixture(t), a = await h.device(); + const room = await h.start(a, mode), session = h.server.registry.byId(a.welcome.playerId); + await h.disconnect(a); + const at = session.disconnectedAt; + assert.ok(at != null); + assert.equal(h.server.registry.windowOf(session), mode === 'solo' ? 86_400_000 : 600_000); + const b = await h.device(); + const item = (await h.request(b, 'recovery')).body.recovery; + assert.equal(item.connected, false); + assert.equal(session.disconnectedAt, at, 'looking at a recovery never renews its deadline'); + await h.enter(b, item.id); + assert.equal((await b.client.waitFor('room.state')).code, room.code); + assert.equal(session.disconnectedAt, null); + assert.equal(session.connected, true); + }); +}); + +test('recovery never follows a supplied player, nickname, UID, region or foreign recovery selector', async (t) => { + const h = await fixture(t), a = await h.device(), room = await h.start(a); + const item = (await h.request(a, 'recovery')).body.recovery; + const strangers = [await h.device([role('456')]), await h.device([role('123', 'B服')]), + await h.device([role('123'), role('456', '官服', { isDefault: true })])]; + for (const b of strangers) { + assert.equal((await h.request(b, 'recovery?player=' + a.welcome.playerId)).body.recovery, null); + const result = await h.request(b, 'resume', { id: item.id, player: a.welcome.playerId, + uid: '123', channelName: '官服', name: a.welcome.name }); + assert.equal(result.status, 409); assert.equal(result.body.code, 'resume_expired'); + const stolen = await h.connect(b.cookie, a.token); + assert.notEqual(stolen.welcome.playerId, a.welcome.playerId); + assert.equal(stolen.client.log.some((frame) => frame.t === 'm.private'), false); + } + const multi = strangers[2]; + assert.equal((await h.request(multi, 'role/select', { roleKey: roleKey(role('123')) })).status, 200); + assert.equal((await h.request(multi, 'recovery')).body.recovery.code, room.code); + const anonymous = await h.connect(); + assert.equal((await h.request(anonymous, 'recovery')).status, 401); + assert.equal((await h.request(anonymous, 'resume', { id: item.id })).status, 401); + for (const endpoint of ['enter', 'resume']) { + assert.equal((await h.request(multi, endpoint, { id: item.id }, { origin: 'https://attacker.invalid' })).status, 403); + } + assert.equal(a.client.isOpen, true); +}); + +test('a stale or expired recovery is refused without creating a session; later entry disposes the old seat first', async (t) => { + const h = await fixture(t), a = await h.device(), room = await h.start(a); + const session = h.server.registry.byId(a.welcome.playerId), item = (await h.request(a, 'recovery')).body.recovery; + await h.disconnect(a); + const at = session.disconnectedAt + h.server.registry.windowOf(session) + 1; + h.server.registry.now = () => at; h.server.network.now = () => at; + const b = await h.device(); + assert.equal((await h.request(b, 'recovery')).body.recovery, null); + const stale = await h.request(b, 'resume', { id: item.id }); + assert.equal(stale.status, 409); assert.equal(stale.body.code, 'resume_expired'); + assert.equal(h.server.registry.byId(session.playerId), session, 'a failed restore does not mint a fresh game'); + await h.enter(b); + assert.notEqual(h.server.registry.byId(session.playerId), session); + assert.equal(session.roomCode, null); + assert.equal(room.disposed, true, 'the expired solo match is abandoned, not orphaned under the stable playerId'); + assert.equal((await h.request(b, 'recovery')).body.recovery, null); + assert.equal(b.client.log.some((frame) => frame.t === 'm.private'), false); +}); + +test('a room that starts a later match invalidates the old selector without stealing its connected seat', async (t) => { + const h = await fixture(t), a = await h.device(), b = await h.device(); + await h.enter(a); + assert.equal((await a.client.request({ t: 'room.create', mode: 'solo', difficulty: 'NORMAL' })).t, 'ok'); + const first = (await h.request(b, 'recovery')).body.recovery; + assert.equal(first.inMatch, false); + assert.equal((await a.client.request({ t: 'room.start' })).t, 'ok'); + assert.equal((await h.request(b, 'resume', { id: first.id })).status, 409); + const current = (await h.request(b, 'recovery')).body.recovery; + assert.notEqual(current.id, first.id); assert.equal(current.code, first.code); + assert.equal(a.client.isOpen, true); + await h.enter(b, current.id); + assert.equal((await a.client.closed).code, 4001); +}); + + +test('stable account identities and normal fields fit every existing client message limit', () => { + const playerId = accountPlayerId(role()), fieldId = 'n:' + playerId; + const perPlayer = { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, unitsEnd: [] }; + for (const message of [ + { t: 'room.kick', seat: 1, playerId }, + { t: 'room.removeSpectator', playerId }, + { t: 'g.watch', fieldId, playerId }, + { t: 'b.result', battleId: 'match.1', result: { reason: 'cleared', time: 1, perPlayer: { [playerId]: perPlayer } } }, + ]) assert.equal(validateC2S(message), null, message.t + ' accepts the account identity'); + assert.equal(playerId, accountPlayerId(role(123, ' 官服 ', { nickName: '新名字', gameId: 2 }))); +}); diff --git a/test/skland/scan.test.js b/test/skland/scan.test.js new file mode 100644 index 00000000..04ae6c26 --- /dev/null +++ b/test/skland/scan.test.js @@ -0,0 +1,120 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createQrLogin } from '../../public/js/skland/scan.js'; + +const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }; +function fakeClock() { + let time = 0, id = 0; + const tasks = new Map(); + return { + now: () => time, + timers: { + setTimeout: (fn, ms) => { tasks.set(++id, { fn, at: time + ms }); return id; }, + clearTimeout: (key) => tasks.delete(key), + }, + count: () => tasks.size, + async advance(ms) { + const target = time + ms; + for (;;) { + const next = [...tasks].sort((a, b) => a[1].at - b[1].at)[0]; + if (!next || next[1].at > target) break; + time = next[1].at; tasks.delete(next[0]); next[1].fn(); await flush(); + } + time = target; await flush(); + }, + }; +} +function fixture(poll = () => ({ ok: true, state: 'waiting' }), onConfirmed) { + const clock = fakeClock(), calls = [], images = [], pending = [], errors = []; + let confirmed = 0, starts = 0; + const scan = createQrLogin({ ...clock, + request: async (path) => { + calls.push({ path, at: clock.now() }); + return path === 'scan/start' ? { ok: true, scanUrl: `QR${++starts}` } : poll(); + }, + encode: async (url) => `image:${url}`, onQr: (value) => images.push(value), + onPending: (value) => pending.push(value), onError: (value) => { if (value) errors.push(value); }, + onBusy: () => {}, onConfirmed: async () => { confirmed++; await onConfirmed?.(); }, + }); + return { scan, clock, calls, images, pending, errors, starts: () => starts, confirmed: () => confirmed }; +} + +test('QR is fetched automatically, polled every 2 s and replaced every 10 s', async () => { + const f = fixture(); f.scan.setActive(true); await f.clock.advance(0); + assert.deepEqual(f.images, ['image:QR1']); + await f.clock.advance(9999); assert.equal(f.starts(), 1); + await f.clock.advance(1); assert.equal(f.starts(), 2); + assert.deepEqual(f.calls.filter((c) => c.path === 'scan/poll').map((c) => c.at), [2000, 4000, 6000, 8000, 10000]); + await f.clock.advance(10000); assert.equal(f.starts(), 3); + f.scan.stop(); assert.equal(f.clock.count(), 0); +}); + +test('a failed status fetch after confirmation retries completion without replacing the authenticated QR', async () => { + let attempts = 0; + const f = fixture(() => ({ ok: true, state: 'confirmed' }), async () => { + if (++attempts === 1) throw new Error('status offline'); + }); + f.scan.setActive(true); await f.clock.advance(2000); + assert.equal(f.confirmed(), 1); assert.equal(f.errors[0].message, 'status offline'); + await f.clock.advance(10000); + assert.equal(f.confirmed(), 2); assert.equal(f.starts(), 1); assert.equal(f.clock.count(), 0); + f.scan.stop(); +}); + +test('the first awaiting-confirmation event starts a 60 s window; repeated polls do not extend it', async () => { + const f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: true })); + f.scan.setActive(true); await f.clock.advance(2000); + assert.equal(f.pending.at(-1), true); + await f.clock.advance(59999); assert.equal(f.starts(), 1); + await f.clock.advance(1); assert.equal(f.starts(), 2); + assert.equal(f.calls.filter((c) => c.path === 'scan/start')[1].at, 62000); + assert.equal(f.pending.at(-1), false, 'a new QR returns to the ordinary interval'); + f.scan.refresh(); await f.clock.advance(0); assert.equal(f.starts(), 3, 'manual refresh is immediate too'); + f.scan.stop(); +}); + +test('a scan detected at the 10 s refresh boundary keeps the QR for confirmation', async () => { + let f; + f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: f.clock.now() >= 10000 })); + f.scan.setActive(true); await f.clock.advance(10000); + assert.equal(f.starts(), 1); assert.equal(f.pending.at(-1), true); + await f.clock.advance(59999); assert.equal(f.starts(), 1); + await f.clock.advance(1); assert.equal(f.starts(), 2); + f.scan.stop(); +}); + +test('pausing stops requests and resuming checks confirmation before replacing an overdue QR', async () => { + const f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: true })); + f.scan.setActive(true); await f.clock.advance(0); + f.scan.setActive(false); assert.equal(f.clock.count(), 0); + await f.clock.advance(50000); assert.equal(f.calls.length, 1); + f.scan.setActive(true); await f.clock.advance(0); + assert.equal(f.starts(), 1); assert.equal(f.pending.at(-1), true); + f.scan.stop(); await f.clock.advance(120000); assert.equal(f.calls.length, 2); +}); + +test('confirmation wins over a manual refresh queued during an in-flight poll', async () => { + let resolvePoll; + const f = fixture(() => new Promise((resolve) => { resolvePoll = resolve; })); + f.scan.setActive(true); await f.clock.advance(2000); + f.scan.refresh(); assert.equal(f.starts(), 1, 'a refresh never overlaps its poll'); + resolvePoll({ ok: true, state: 'confirmed' }); await flush(); + assert.equal(f.confirmed(), 1); assert.equal(f.clock.count(), 0); + await f.clock.advance(120000); assert.equal(f.starts(), 1); + f.scan.stop(); +}); + +test('stopping ignores late replies, and a failed poll can recover with a fresh QR', async () => { + let resolvePoll; + const stopped = fixture(() => new Promise((resolve) => { resolvePoll = resolve; })); + stopped.scan.setActive(true); await stopped.clock.advance(2000); stopped.scan.stop(); + resolvePoll({ ok: true, state: 'confirmed' }); await flush(); + assert.equal(stopped.confirmed(), 0); assert.equal(stopped.clock.count(), 0); + + const retry = fixture(() => { throw new Error('offline'); }); + retry.scan.setActive(true); await retry.clock.advance(2000); + assert.equal(retry.errors[0].message, 'offline'); + await retry.clock.advance(9999); assert.equal(retry.starts(), 1); + await retry.clock.advance(1); assert.equal(retry.starts(), 2); + retry.scan.stop(); +}); diff --git a/test/skland/session.test.js b/test/skland/session.test.js new file mode 100644 index 00000000..2e4c247b --- /dev/null +++ b/test/skland/session.test.js @@ -0,0 +1,156 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createHmac } from 'node:crypto'; +import { WebSocket } from 'ws'; +import { startServer } from '../../server/index.js'; +import { ACCOUNT_COOKIE, LOGIN_TTL } from '../../server/skland/auth.js'; +import { roleKey } from '../../shared/accountIdentity.js'; +import { PROTOCOL_VERSION } from '../../shared/constants.js'; + +const role = (uid = '123', channelName = '官服', gameId = 1) => ({ uid, channelName, gameId, nickName: '同名博士' }); +async function request(server, endpoint, { token, cookie, body, headers = {} } = {}) { + const response = await fetch(server.url + endpoint, { method: body === undefined ? 'GET' : 'POST', + headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(cookie ? { cookie } : {}), + 'content-type': 'application/json', ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + return { status: response.status, body: await response.json(), setCookie: response.headers.get('set-cookie') }; +} +async function login(server, roles = [role()]) { + const session = server.registry.create('temporary'); + server.skland._store.update('@session/' + session.playerId, { status: 'bound', nickname: roles[0].nickName, + binding: { list: [{ appCode: 'arknights', bindingList: roles }] } }); + const result = await request(server, '/skland/session/status', { token: session.token }); + assert.equal(result.status, 200); + return { token: session.token, cookie: result.setCookie.split(';')[0], state: result.body, setCookie: result.setCookie }; +} +async function socket(server, account = {}) { + const ws = new WebSocket(server.url.replace('http:', 'ws:') + '/ws', { headers: account.cookie ? { cookie: account.cookie } : {} }); + await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); }); + let rid = 0; + const send = (message) => new Promise((resolve, reject) => { + const id = ++rid; + const timer = setTimeout(() => { ws.off('message', receive); reject(new Error('socket reply timeout')); }, 3000); + const receive = (data) => { const reply = JSON.parse(data); + if (reply.rid === id) { clearTimeout(timer); ws.off('message', receive); resolve(reply); } }; + ws.on('message', receive); ws.send(JSON.stringify({ ...message, rid: id })); + }); + const entry = account.cookie && await request(server, '/skland/session/enter', { ...account, body: {} }); + const welcome = await send({ t: 'hello', name: '伪造昵称', token: entry?.body.token || account.token, version: PROTOCOL_VERSION }); + return { ws, send, welcome }; +} + +test('account HTTP rejects forged JWT/roles and cross-origin access; logout revokes replay and diagnostics are closed', async () => { + const authSecret = 'test-only-key-never-a-real-deployment-123456789'; + const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', authSecret } }); + try { + const a = await login(server), b = await login(server, [role('456')]); + assert.match(a.setCookie, /HttpOnly/); assert.match(a.setCookie, /SameSite=Strict/); assert.match(a.setCookie, /Max-Age=2592000/); + assert.equal((await request(server, '/skland/session/status', { cookie: a.cookie })).status, 401); + assert.equal((await request(server, '/skland/session/status?token=' + a.token, { cookie: a.cookie })).status, 401); + assert.equal((await request(server, '/skland/session/status?player=' + encodeURIComponent(b.state.playerId), a)).body.playerId, a.state.playerId); + assert.equal((await request(server, '/skland/session/role/select', { ...a, body: { roleKey: roleKey(role('456')) } })).status, 403); + for (const headers of [{ origin: 'https://attacker.invalid' }, { 'sec-fetch-site': 'cross-site' }]) { + assert.equal((await request(server, '/skland/session/status', { ...a, headers })).status, 403); + } + for (const url of ['/skland/status?player=' + a.state.playerId, '/skland/roles?player=someone']) { + assert.equal((await request(server, url, a)).status, 403); + } + const jwt = a.cookie.slice(ACCOUNT_COOKIE.length + 1).split('.'), claims = JSON.parse(Buffer.from(jwt[1], 'base64url')); + assert.ok(!JSON.stringify(claims).includes('access_token')); + for (const forged of [ + [jwt[0], Buffer.from(JSON.stringify({ ...claims, sub: b.state.playerId })).toString('base64url'), jwt[2]], + [Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'), jwt[1], jwt[2]], + ]) assert.equal((await request(server, '/skland/session/status', { ...a, cookie: `${ACCOUNT_COOKIE}=${forged.join('.')}` })).status, 401); + for (const invalid of [{ iss: 'another-server' }, { aud: 'another-app' }, { iat: claims.exp + 1 }, { role: roleKey(role('456')) }]) { + const body = Buffer.from(JSON.stringify({ ...claims, ...invalid })).toString('base64url'); + const input = jwt[0] + '.' + body; + const signed = input + '.' + createHmac('sha256', authSecret).update(input).digest('base64url'); + assert.equal((await request(server, '/skland/session/status', { ...a, cookie: `${ACCOUNT_COOKIE}=${signed}` })).status, 401); + } + await request(server, '/skland/session/unbind', { ...b, body: { player: a.state.playerId } }); + assert.equal((await request(server, '/skland/session/status', a)).body.bound, true, 'B only unbinds B'); + await request(server, '/skland/session/unbind', { ...a, body: {} }); + assert.equal((await request(server, '/skland/session/status', a)).status, 401); + assert.equal((await request(server, '/skland/session/status', { token: a.token })).body.bound, false, 'old transport cannot reissue JWT'); + } finally { await server.close(); } +}); + +test('late provider replies cannot restore credentials after unbind or replace a newer scan', async () => { + let resolveScan, resolveBinding, announceBinding, scans = 0, tokenExchanges = 0; + const binding = { list: [{ appCode: 'arknights', bindingList: [role()] }] }; + const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', client: { + genScan: async () => ({ scanId: String(++scans), scanUrl: 'test:qr' }), + checkScan: async () => new Promise((resolve) => { resolveScan = resolve; }), + tokenByScanCode: async () => { tokenExchanges++; return { token: 'TEST' }; }, + tokenByPhonePassword: async () => ({ token: 'TEST' }), + getAccountBinding: async () => { announceBinding(); return new Promise((resolve) => { resolveBinding = resolve; }); }, + } } }); + try { + const service = server.skland; + for (const action of [() => service.unbind('pending'), () => service.startScan('pending')]) { + await service.startScan('pending'); + const pending = service.pollScan('pending'); + await action(); resolveScan({ state: 'confirmed', scanCode: 'OLD' }); + assert.equal((await pending).state, 'none'); + assert.equal(service.status('pending').bound, false); + } + assert.equal(tokenExchanges, 0); + const reachedBinding = new Promise((resolve) => { announceBinding = resolve; }); + const password = service.loginByPhonePassword('pending', '13800000000', 'test'); + await reachedBinding; service.unbind('pending'); resolveBinding(binding); + assert.equal((await password).ok, false); + assert.equal(service._store.get('pending').access_token, null); + assert.equal(service.status('pending').bound, false); + } finally { await server.close(); } +}); + +test('JWT and bindings survive restart, expire after 30 days, and credential files cannot be downloaded', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-account-')); + let at = Date.now(), server; + const options = { port: 0, host: '127.0.0.1', quiet: true, publicDir: dir, + skland: { storeFile: path.join(dir, 'private.db'), authNow: () => at } }; + try { + server = await startServer(options); const a = await login(server); + for (const name of ['private.db', 'PRIVATE.DB', 'private.db-wal', 'private.db-shm']) assert.equal((await fetch(server.url + '/' + name)).status, 404); + try { fs.symlinkSync(path.join(dir, 'private.db'), path.join(dir, 'alias.txt')); + assert.equal((await fetch(server.url + '/alias.txt')).status, 404); + } catch (error) { if (!['EPERM', 'EACCES'].includes(error.code)) throw error; } + await server.close(); server = await startServer(options); + const fresh = server.registry.create('new transport'); + const restored = await request(server, '/skland/session/status', { token: fresh.token, cookie: a.cookie }); + assert.equal(restored.body.gamePlayerId, a.state.gamePlayerId); assert.equal(restored.body.playerId, a.state.playerId); + assert.equal(restored.body.bound, true); + at += (LOGIN_TTL + 1) * 1000; + const expired = await request(server, '/skland/session/status', { token: fresh.token, cookie: a.cookie }); + assert.equal(expired.status, 401); assert.match(expired.setCookie, /Max-Age=0/); + } finally { await server?.close(); fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('game identity uses UID plus region, permits duplicate nicknames, and blocks anonymous game requests', async () => { + const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:' } }); + const sockets = []; + try { + const accounts = [await login(server), await login(server, [role('456')]), await login(server, [role('123', 'B服', 2)])]; + for (const account of accounts) sockets.push(await socket(server, account)); + const welcomes = sockets.map((client) => client.welcome); + assert.ok(welcomes.every((welcome) => welcome.name === '同名博士')); + assert.equal(new Set(welcomes.map((welcome) => welcome.playerId)).size, 3); + assert.equal((await sockets[0].send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'ok'); + const anonymous = await socket(server, { token: welcomes[0].token }); sockets.push(anonymous); + assert.notEqual(anonymous.welcome.playerId, welcomes[0].playerId); + assert.equal((await anonymous.send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'error'); + assert.equal((await request(server, '/skland/session/status', { token: welcomes[0].token, cookie: accounts[1].cookie })).status, 403); + const reconnect = await socket(server, accounts[1]); sockets.push(reconnect); + assert.equal(reconnect.welcome.playerId, welcomes[1].playerId); + await request(server, '/skland/session/unbind', { token: reconnect.welcome.token, cookie: accounts[1].cookie, body: {} }); + assert.equal((await reconnect.send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'error'); + } finally { for (const client of sockets) client.ws.terminate(); await server.close(); } +}); + +test('a disabled server keeps the account surface off', async () => { + const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + try { assert.equal((await fetch(server.url + '/skland/session/status')).status, 404); } + finally { await server.close(); } +}); diff --git a/test/skland/store.test.js b/test/skland/store.test.js new file mode 100644 index 00000000..9bf032c3 --- /dev/null +++ b/test/skland/store.test.js @@ -0,0 +1,168 @@ +// The per-player credential store (server/skland/store.js): node:sqlite in WAL mode, one row per player. +// +// The store is what survives a restart, so the round-trip of every column and the 解绑 semantics are pinned here. +// The file-backed cases also assert the WAL side files exist, which is what makes the SQLite path a "high-performance +// local database" rather than a map that happens to be persisted. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { createSklandStore } from '../../server/skland/store.js'; + +const tempDir = () => mkdtempSync(join(tmpdir(), 'sp-skland-')); + +test('a player row starts unbound and ensure() is idempotent', () => { + const store = createSklandStore({ file: ':memory:' }); + try { + store.ensure('p1'); + store.ensure('p1'); + const row = store.get('p1'); + assert.equal(store.count(), 1); + assert.equal(row.player_id, 'p1'); + assert.equal(row.status, 'unbound'); + assert.equal(row.cred, null); + assert.equal(row.binding, null); + assert.ok(row.created_at > 0); + } finally { store.close(); } +}); + +test('get() of an unknown player is null; update() creates the row', () => { + const store = createSklandStore({ file: ':memory:' }); + try { + assert.equal(store.get('nobody'), null); + store.update('p2', { status: 'bound', cred: 'C', cred_token: 'CT' }); + assert.equal(store.get('p2').status, 'bound'); + assert.equal(store.count(), 1, 'update() creates exactly one row'); + } finally { store.close(); } +}); + +test('the whole login chain round-trips, including the binding JSON', () => { + const store = createSklandStore({ file: ':memory:' }); + try { + const binding = { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士' }] }] }; + store.update('p1', { + access_token: 'AT', device_token: 'DT', hg_id: 'HG', cred: 'C', cred_token: 'CT', + user_id: 'U', d_id: 'D', nickname: '博士', binding, status: 'bound', + }); + const row = store.get('p1'); + assert.equal(row.access_token, 'AT'); + assert.equal(row.device_token, 'DT'); + assert.equal(row.hg_id, 'HG'); + assert.equal(row.cred, 'C'); + assert.equal(row.cred_token, 'CT'); + assert.equal(row.d_id, 'D'); + assert.equal(row.nickname, '博士'); + assert.deepEqual(row.binding, binding, 'binding is stored as JSON and parsed back'); + } finally { store.close(); } +}); + +test('update() merges only the named fields', () => { + const store = createSklandStore({ file: ':memory:' }); + try { + store.update('p1', { cred: 'C', status: 'bound', nickname: 'N' }); + store.update('p1', { cred_token: 'CT' }); + const row = store.get('p1'); + assert.equal(row.cred, 'C'); + assert.equal(row.nickname, 'N'); + assert.equal(row.status, 'bound'); + assert.equal(row.cred_token, 'CT'); + } finally { store.close(); } +}); + +test('unbind() forgets every credential and the binding source but keeps the row', () => { + const store = createSklandStore({ file: ':memory:' }); + try { + store.update('p1', { + access_token: 'AT', device_token: 'DT', hg_id: 'HG', cred: 'C', cred_token: 'CT', + user_id: 'U', d_id: 'D', nickname: 'N', binding: { list: [] }, binding_source: 'hypergryph', status: 'bound', + }); + store.unbind('p1'); + const row = store.get('p1'); + assert.equal(row.status, 'unbound'); + for (const field of ['access_token', 'hg_id', 'cred', 'cred_token', 'user_id', 'd_id', 'nickname']) { + assert.equal(row[field], null, `${field} is cleared`); + } + assert.equal(row.binding, null); + assert.equal(row.device_token, null, 'the device token is cleared with the rest of the credentials'); + assert.equal(row.binding_source, 'skland', 'unbound rows return to the legacy default'); + assert.equal(row.player_id, 'p1', 'the row stays so a rebind is cheap'); + } finally { store.close(); } +}); + +test('a legacy database migrates without losing QR bindings and persists the official account source', () => { + const dir = tempDir(); + const file = join(dir, 'legacy.db'); + try { + const initial = createSklandStore({ file }); + initial.update('old-player', { cred: 'OLD-CRED', device_token: 'OLD-DEVICE', status: 'bound', binding: { list: [] } }); + initial.close(); + const legacy = new DatabaseSync(file); + legacy.exec('ALTER TABLE skland_players DROP COLUMN binding_source'); + legacy.close(); + + const migrated = createSklandStore({ file }); + assert.equal(migrated.get('old-player').cred, 'OLD-CRED'); + assert.equal(migrated.get('old-player').device_token, 'OLD-DEVICE'); + assert.equal(migrated.get('old-player').binding_source, 'skland'); + migrated.update('password-player', { access_token: 'HG-TOKEN', binding_source: 'hypergryph', status: 'bound', binding: { list: [] } }); + migrated.close(); + const reopened = createSklandStore({ file }); + assert.equal(reopened.get('password-player').binding_source, 'hypergryph'); + assert.equal(reopened.get('password-player').access_token, 'HG-TOKEN'); + assert.equal(reopened.get('password-player').cred, null); + assert.equal(reopened.get('old-player').status, 'bound'); + reopened.close(); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('remove() drops the row entirely', () => { + const store = createSklandStore({ file: ':memory:' }); + try { + store.update('p1', { status: 'bound' }); + store.remove('p1'); + assert.equal(store.get('p1'), null); + assert.equal(store.count(), 0); + } finally { store.close(); } +}); + +test('list() returns every row, newest update first', () => { + const store = createSklandStore({ file: ':memory:' }); + try { + store.update('a', { status: 'bound' }); + store.update('b', { status: 'unbound' }); + const ids = store.list().map((r) => r.player_id).sort(); + assert.deepEqual(ids, ['a', 'b']); + } finally { store.close(); } +}); + +test('a file-backed store persists across instances and uses WAL', () => { + const dir = tempDir(); + const file = join(dir, 'skland.db'); + try { + const first = createSklandStore({ file }); + first.update('p1', { cred: 'C', status: 'bound', binding: { list: [{ appCode: 'arknights', bindingList: [] }] } }); + assert.equal(first.journalMode(), 'wal', 'WAL mode is on (readers do not block the writer)'); + first.close(); + assert.ok(existsSync(file), 'the database file exists on disk'); + + const second = createSklandStore({ file }); + const row = second.get('p1'); + assert.equal(row.cred, 'C', 'the credentials survive a restart'); + assert.equal(row.status, 'bound'); + assert.deepEqual(row.binding.list[0].appCode, 'arknights'); + second.close(); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('the parent directory of the database file is created', () => { + const dir = tempDir(); + const file = join(dir, 'nested', 'deeper', 'skland.db'); + try { + const store = createSklandStore({ file }); + store.update('p1', { status: 'bound' }); + assert.ok(existsSync(file)); + store.close(); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/tools/vendor.mjs b/tools/vendor.mjs index f91141ed..6d89b01d 100644 --- a/tools/vendor.mjs +++ b/tools/vendor.mjs @@ -40,6 +40,9 @@ export const VENDOR_FILES = Object.freeze([ ['node_modules/htm/dist/htm.module.js', 'htm.module.js'], ['node_modules/three/build/three.core.js', 'three.core.js', true], ['node_modules/three/build/three.module.js', 'three.module.js', true], + // the 森空岛 binding panel (public/skland.html) renders the scan login QR client-side; the UMD build sets a global + // `qrcode(typeNumber, errorCorrectionLevel)` when loaded as a classic script, so this one is not an ES module. + ['node_modules/qrcode-generator/dist/qrcode.js', 'qrcode.js'], ]); export function vendor({ log = console.log, warn = console.warn } = {}) {