// server/skland/client.js — HG account bindings and the legacy Skland login/signed API client. Every call uses an // (i18n-ignore-file: what this file holds is not UI text — the Chinese strings are the internal chain-step labels of // errors and logs, or the message of an upstream failure the HTTP layer turns into `{ ok: false, error }`, exactly the // "Error messages are not player text" case of docs/I18N.md; the panel owns the wording a player reads) // injected `fetchImpl` (default: globalThis.fetch), so tests run the whole chain against a stub and nothing here // touches the network unless the server does. Account bindings follow the official account SDK; the legacy Skland // endpoints/signing also follow community documentation (3mua.cn and TNXG/skland-api). Signing lives in ./crypto.js. // // The existing QR/SMS paths use Skland; password lookup uses the official HG account binding service: // 扫码 genScan → pollScan → tokenByScanCode → (token + deviceToken) → grant → cred // 验证码 sendPhoneCode → tokenByPhoneCode → (token + hgId + deviceToken) → grant → cred // 密码 tokenByPhonePassword → token → binding-service grant (type 1) → Arknights binding_list // // A deviceToken is optional in the official account SDK's grant request. Password login must attempt the grant // even on a fresh local player; only upstream status 109 (NEED_DEVICE_VERIFY) requires extra verification. // Source: https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js (checked 2026-10-14). // // SMS/password/account grants may ask for Geetest (status 1). The browser displays the interactive widget and // returns its proof to the challenged step; the server never solves the challenge or exposes account tokens. // // Cred refresh: code 10000 → cred_token expired → POST zonai /auth/refresh, retry once; code 10002 → cred expired → // re-run grant → cred from the stored access_token (+ deviceToken), retry once; otherwise the player re-binds. import { SKLAND_APP_CODE, SKLAND_UA, signedHeaders, deviceHeaders } from './crypto.js'; const AS_BASE = 'https://as.hypergryph.com'; const ZONAI_BASE = 'https://zonai.skland.com'; const BINDING_BASE = 'https://binding-api-account-prod.hypergryph.com'; // Official account SDK: Account.API.grantApp('binding'), app registry entry (checked 2026-10-14). const BINDING_APP_CODE = 'be36d44aa36bfb5b'; const POLL_INTERVAL_MS = 2000; const POLL_TIMEOUT_MS = 120_000; /** Error carrying the upstream's message; `code` is the upstream status/code field, `step` the chain step. */ export class SklandError extends Error { /** * @param {string} message @param {number | string} [code] @param {string} [step] */ constructor(message, code, step) { super(message); this.name = 'SklandError'; this.code = code; this.step = step; } } /** The upstream requested an interactive Geetest challenge; the browser completes it and retries that step. */ export class SklandCaptchaError extends SklandError { /** * @param {{ gt?: string, challenge?: string, new_captcha?: boolean, geetestId?: string, riskType?: string }} captcha */ constructor(captcha, step = 'sendPhoneCode') { super('需要人机验证,请完成验证后重试', 'captcha', step); this.name = 'SklandCaptchaError'; this.captcha = captcha; } } /** The upstream requested device verification (109); absence of a local deviceToken alone is not an error. */ export class SklandNeedDeviceError extends SklandError { constructor(message = '当前设备需要额外验证', step = 'getGrantCode') { super(`${message},请使用验证码或扫码完成登录`, 109, step); this.name = 'SklandNeedDeviceError'; } } async function readJson(res, step) { try { return await res.json(); } catch { throw new SklandError(`${step}:响应不是 JSON (HTTP ${res.status})`, res.status, step); } } /** as.hypergryph.com answers { status: 0, msg?, data? }; returns `data`. */ function assertAs(json, step, captchaStep = step) { if (json.status === 1 && json.data?.captcha) throw new SklandCaptchaError(json.data.captcha, captchaStep); if (json.status === 109) throw new SklandNeedDeviceError(json.msg || undefined, step); if (json.status !== 0) throw new SklandError(json.msg || `${step}失败`, json.status, step); return json.data || {}; } /** zonai.skland.com answers { code: 0, message?, data }; returns `data`. */ function assertZonai(json, step) { if (json.code !== 0) throw new SklandError(json.message || json.msg || `${step}失败`, json.code, step); return json.data || {}; } /** * @param {{ * fetchImpl?: typeof fetch, * baseHeaders?: Record, * pollIntervalMs?: number, * pollTimeoutMs?: number, * sleep?: (ms: number) => Promise, * }} [opts] */ export function createSklandClient(opts = {}) { const fetchImpl = opts.fetchImpl || globalThis.fetch; const pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL_MS; const pollTimeoutMs = opts.pollTimeoutMs ?? POLL_TIMEOUT_MS; const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))); const baseHeaders = { 'User-Agent': SKLAND_UA, Accept: 'application/json', ...(opts.baseHeaders || {}) }; async function post(url, body, extraHeaders) { return fetchImpl(url, { method: 'POST', headers: { ...baseHeaders, 'Content-Type': 'application/json', ...(extraHeaders || {}) }, body: JSON.stringify(body ?? {}), }); } // ── 扫码登录 ────────────────────────────────────────────────────────────────────────────── /** * Step 1: request a QR scan session. The QR code must encode `scanUrl` (a `hypergryph://scan_login?…` deep link), * not the scanId — the 森空岛 app's scanner reads the deep link. * @returns {Promise<{ scanId: string, scanUrl: string }>} */ async function genScan() { const res = await post(`${AS_BASE}/general/v1/gen_scan/login`, { appCode: SKLAND_APP_CODE }); const json = await readJson(res, '获取扫码会话'); const data = assertAs(json, '获取扫码会话'); return { scanId: data.scanId, scanUrl: data.scanUrl || `hypergryph://scan_login?scanId=${data.scanId}` }; } /** * Step 2: one status check (the HTTP route polls per request; `pollScan` below loops it). * The official account-center login keeps polling for 100 (unscanned) and 101 (awaiting phone confirmation). * Source: https://web.hycdn.cn/user-center/index.fa9aa713.js (general/v1/scan_status, checked 2026-10-14). * Only status 0 + data.scanCode completes confirmation; 102 and other failures remain errors. * @returns {Promise<{ state: 'waiting' | 'confirmed', scanCode?: string, awaitingConfirmation?: boolean }>} */ async function checkScan(scanId) { const res = await fetchImpl(`${AS_BASE}/general/v1/scan_status?scanId=${encodeURIComponent(scanId)}`, { headers: baseHeaders }); const json = await readJson(res, '查询扫码状态'); if (json.data && json.data.scanCode) return { state: 'confirmed', scanCode: json.data.scanCode }; if (json.status === 100 || json.status === 101) return { state: 'waiting', awaitingConfirmation: json.status === 101 }; throw new SklandError(json.msg || '查询扫码状态失败', json.status, 'checkScan'); } /** * Step 2 (looping): poll until the phone confirms, or the timeout passes. * @param {string} scanId * @param {{ onWait?: (state: string) => void }} [o] * @returns {Promise} the scanCode */ async function pollScan(scanId, o = {}) { const deadline = Date.now() + pollTimeoutMs; for (;;) { const { state, scanCode } = await checkScan(scanId); if (state === 'confirmed') return scanCode; if (Date.now() > deadline) throw new SklandError('扫码超时,请重试', 'timeout', 'pollScan'); o.onWait?.(state); await sleep(pollIntervalMs); } } /** Step 3: scanCode → { token, hgId?, deviceToken? }. */ async function tokenByScanCode(scanCode) { const res = await post(`${AS_BASE}/user/auth/v1/token_by_scan_code`, { appCode: SKLAND_APP_CODE, from: 0, scanCode }, deviceHeaders()); const json = await readJson(res, '扫码换取 token'); const data = assertAs(json, '扫码换取 token'); return { token: data.token, hgId: data.hgId || null, deviceToken: data.deviceToken || null }; } // ── 验证码登录 ──────────────────────────────────────────────────────────────────────────── /** * Send a login/register SMS (type 2, LOGIN_OR_REGISTER), matching the official account center: * https://web.hycdn.cn/user-center/index.fa9aa713.js (checked 2026-10-14). * Rate limited (status 102) and 人机验证 (status 1 + geetest) surface as errors; * retry the latter with the solved `captcha` fields. * @param {string} phone * @param {Record} [captcha] Geetest v3/v4 proof supplied by the browser * @returns {Promise<{ ok: true }>} */ async function sendPhoneCode(phone, captcha) { const body = { phone, type: 2 }; if (captcha) body.captcha = captcha; const res = await post(`${AS_BASE}/general/v1/send_phone_code`, body); const json = await readJson(res, '发送验证码'); if (json.status === 1 && json.data && json.data.captcha) throw new SklandCaptchaError(json.data.captcha); if (json.status === 102) throw new SklandError(json.msg || '发送频率太高,请稍后再试', 102, 'sendPhoneCode'); assertAs(json, '发送验证码'); return { ok: true }; } /** 验证码 → { token, hgId, deviceToken } (this endpoint always returns the deviceToken). */ async function tokenByPhoneCode(phone, code) { const res = await post(`${AS_BASE}/user/auth/v2/token_by_phone_code`, { appCode: SKLAND_APP_CODE, code, phone }, deviceHeaders()); const json = await readJson(res, '验证码换取 token'); const data = assertAs(json, '验证码换取 token'); return { token: data.token, hgId: data.hgId || null, deviceToken: data.deviceToken || null }; } // ── 密码登录 ────────────────────────────────────────────────────────────────────────────── /** 密码 → { token }; a deviceToken is not required locally to try the following grant. */ async function tokenByPhonePassword(phone, password, captcha) { const body = { phone, password }; if (captcha) body.captcha = captcha; const res = await post(`${AS_BASE}/user/auth/v1/token_by_phone_password`, body, deviceHeaders()); const json = await readJson(res, '密码换取 token'); const data = assertAs(json, '密码换取 token', 'password'); if (!data.token) throw new SklandError('密码登录未返回 token', 'no_token', 'tokenByPhonePassword'); return { token: data.token }; } /** * Query Arknights account bindings without a Skland account or cred. * Official SDK: Account.API.grantApp('binding') → Role.API.getServerRoleList('ak'). * Source: https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js (2026-10-14). */ async function getAccountBinding(accessToken, captcha) { const body = { token: accessToken, appCode: BINDING_APP_CODE, type: 1 }; if (captcha) body.captcha = captcha; const res = await post(`${AS_BASE}/user/oauth2/v2/grant`, body, deviceHeaders()); const grant = assertAs(await readJson(res, '授权查询账号绑定'), '授权查询账号绑定', 'accountGrant'); if (!grant.token) throw new SklandError('账号绑定授权未返回 token', 'no_token', 'getAccountBinding'); const query = new URLSearchParams({ token: grant.token, appCode: 'arknights' }); const response = await fetchImpl(`${BINDING_BASE}/account/binding/v1/binding_list?${query}`, { headers: baseHeaders }); const binding = assertAs(await readJson(response, '查询明日方舟账号绑定'), '查询明日方舟账号绑定'); if (!Array.isArray(binding.list)) throw new SklandError('账号绑定响应缺少角色列表', 'bad_binding', 'getAccountBinding'); return binding; } // ── token → 授权码 → cred → 绑定 ────────────────────────────────────────────────────────── /** * access_token (+ deviceToken when the login path provided one) → grant code. * @param {string} accessToken * @param {string | null} [deviceToken] * @returns {Promise} the grant code */ async function getGrantCode(accessToken, deviceToken = null) { const body = { appCode: SKLAND_APP_CODE, token: accessToken, type: 0 }; if (deviceToken) body.deviceToken = deviceToken; const res = await post(`${AS_BASE}/user/oauth2/v2/grant`, body); const json = await readJson(res, '换取授权码'); return assertAs(json, '换取授权码').code; } /** grant code → { cred, credToken } (this endpoint takes no signature). */ async function getCred(code) { const res = await post(`${ZONAI_BASE}/api/v1/user/auth/generate_cred_by_code`, { code, kind: 1 }); const json = await readJson(res, '换取 cred'); const data = assertZonai(json, '换取 cred'); return { cred: data.cred, credToken: data.token }; } /** A signed GET on zonai (the signature lives in crypto.js). */ async function signedGet(path, query, cred, credToken, dId) { const { headers } = signedHeaders({ cred, credToken, dId, method: 'GET', path, query }); const res = await fetchImpl(`${ZONAI_BASE}${path}${query ? `?${query}` : ''}`, { headers: { ...baseHeaders, ...headers } }); return readJson(res, path); } /** A signed POST on zonai (attendance etc.). */ async function signedPost(path, body, cred, credToken, dId) { const { headers } = signedHeaders({ cred, credToken, dId, method: 'POST', path, body }); const res = await post(`${ZONAI_BASE}${path}`, body, headers); return readJson(res, path); } /** * The account's bound games / roles, refreshing the credential once when the upstream says it expired. * @param {{ cred: string, credToken: string, dId: string, accessToken?: string | null, deviceToken?: string | null }} c * @returns {Promise<{ binding: object, cred: string, credToken: string }>} the (possibly refreshed) credentials */ async function getBinding(c) { let { cred, credToken } = c; const dId = c.dId; let json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId); if (json.code === 10000) { const res = await post(`${ZONAI_BASE}/api/v1/auth/refresh`, {}, { cred }); const data = assertZonai(await readJson(res, '刷新 cred_token'), '刷新 cred_token'); credToken = data.token; json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId); } if (json.code === 10002 && (c.accessToken || c.deviceToken)) { const grantCode = await getGrantCode(c.accessToken, c.deviceToken); const fresh = await getCred(grantCode); cred = fresh.cred; credToken = fresh.credToken; json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId); } if (json.code !== 0) { const message = json.message || json.msg || ''; if (/未登录/.test(message)) throw new SklandError('登录已过期,请重新绑定', json.code, 'getBinding'); throw new SklandError(message || '查询绑定失败', json.code, 'getBinding'); } return { binding: json.data, cred, credToken }; } // ── 整链快捷方式(service.js 用它们组装每个玩家的登录) ────────────────────────────────── /** 扫码整链: scanUrl → scanCode → token (+deviceToken) → grant → cred. */ async function loginByScan({ onWait } = {}) { const { scanId, scanUrl } = await genScan(); const scanCode = await pollScan(scanId, { onWait }); const auth = await tokenByScanCode(scanCode); const grantCode = await getGrantCode(auth.token, auth.deviceToken); const { cred, credToken } = await getCred(grantCode); return { scanId, scanUrl, ...auth, cred, credToken }; } /** 验证码整链: phone + code → token (+hgId+deviceToken) → grant → cred. */ async function loginByPhoneCode(phone, code) { const auth = await tokenByPhoneCode(phone, code); const grantCode = await getGrantCode(auth.token, auth.deviceToken); const { cred, credToken } = await getCred(grantCode); return { ...auth, cred, credToken }; } /** * Password → HG account token → binding-service token (type 1) → Arknights roles. * This flow does not acquire a Skland cred or require a stored deviceToken. */ async function loginByPhonePassword(phone, password) { const { token } = await tokenByPhonePassword(phone, password); const binding = await getAccountBinding(token); return { token, binding }; } return { genScan, checkScan, pollScan, tokenByScanCode, sendPhoneCode, tokenByPhoneCode, tokenByPhonePassword, getGrantCode, getCred, getBinding, getAccountBinding, signedGet, signedPost, loginByScan, loginByPhoneCode, loginByPhonePassword, }; }