Files
herryyun 7c25c89992
CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
实现账号绑定、持久登录与跨设备对局恢复
2026-10-14 23:22:50 +08:00

372 lines
32 KiB
Markdown

# 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=<path>` | the SQLite file (default `<repo>/data/skland.db`) |
| `SP_AUTH_SECRET=<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=<secret>` | 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 — <file>` 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/<endpoint>` with `Authorization: Bearer <game reconnect token>` 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/<playerId>` pending row. After provider confirmation it copies
credentials to a random `@account/<subject>` 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:<playerId>` 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: <admin-secret>'
curl -s -X POST http://127.0.0.1:3000/skland/phone/login \
-H 'X-SP-Diagnostic-Key: <admin-secret>' -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":<ts>,"dId":<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.