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

32 KiB

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, 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 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, 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 and its login implementation, 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'.

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.

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 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: hypergryph.d69547.png and 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.