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
372 lines
32 KiB
Markdown
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.
|