// server/skland/store.js — the per-player Skland credential store (docs/PACKS.md keeps packs data-only; this is // server code, not a pack). A local, high-performance embedded database: node:sqlite (bundled with Node ≥ 22) in // WAL mode — no native build step, no extra dependency, survives restarts, and the Docker image mounts one file. // // One row per pending transport, JWT account subject or administrator diagnostic id, holding the login chain state: // access_token the HG account token (refreshes official bindings or rebuilds a Skland cred) // cred the long-term API credential // cred_token the HMAC signing key (never leaves the server) // user_id the Skland internal user id // d_id the device fingerprint the credentials are pinned to // nickname / binding what the account reports (cache; refreshed on demand) // binding_source selects official HG account lookup or the legacy Skland refresh path // // The store is synchronous (SQLite is), so callers never await; it is created per server instance (tests pass their // own file, `:memory:` for pure in-memory). Reserved keys and account ownership are enforced by accountHttp.js. import { DatabaseSync } from 'node:sqlite'; import { mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; const SCHEMA = ` CREATE TABLE IF NOT EXISTS skland_players ( player_id TEXT PRIMARY KEY, access_token TEXT, device_token TEXT, hg_id TEXT, cred TEXT, cred_token TEXT, user_id TEXT, d_id TEXT, nickname TEXT, binding TEXT, -- the raw binding list from the selected provider (cache) binding_source TEXT NOT NULL DEFAULT 'skland', -- skland | hypergryph status TEXT NOT NULL DEFAULT 'unbound', -- unbound | scanning | bound | error error TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_skland_status ON skland_players(status); `; /** * Node ≥ 22.5 has node:sqlite; an older 22.x has not. The store falls back to an in-memory map so the server still * boots (the credentials then do not survive a restart — logged once). * @param {{ file?: string, log?: object }} [opts] file: the database path (default data/skland.db; ':memory:' for * tests). The parent directory is created when missing. */ export function createSklandStore(opts = {}) { const file = opts.file || join(process.cwd(), 'data', 'skland.db'); const log = opts.log || { warn: () => {} }; if (typeof DatabaseSync !== 'function') { log.warn?.('[skland] node:sqlite unavailable on this Node — using an in-memory store (credentials lost on restart)'); return createMemoryStore(file); } if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true }); const db = new DatabaseSync(file); db.exec('PRAGMA journal_mode = WAL;'); db.exec('PRAGMA synchronous = NORMAL;'); db.exec('PRAGMA foreign_keys = ON;'); db.exec(SCHEMA); // Preserve existing QR/SMS bindings when opening a database created before official account lookup was added. if (!db.prepare('PRAGMA table_info(skland_players)').all().some((column) => column.name === 'binding_source')) { db.exec("ALTER TABLE skland_players ADD COLUMN binding_source TEXT NOT NULL DEFAULT 'skland'"); } const now = () => Date.now(); /** @returns {Record | null} the row as an object, or null */ function rowToObject(row) { if (!row) return null; return { ...row, binding: row.binding ? JSON.parse(row.binding) : null }; } return { /** The database file (or ':memory:'). */ file, /** The SQLite journal mode actually in force ('wal' on a file-backed store; 'memory' for the fallback). */ journalMode() { try { return String(db.prepare('PRAGMA journal_mode').get().journal_mode).toLowerCase(); } catch { return 'unknown'; } }, close() { db.close(); }, /** Ensure a row exists for the player (idempotent). */ ensure(playerId) { db.prepare( 'INSERT INTO skland_players (player_id, status, created_at, updated_at) VALUES (?, ?, ?, ?) ' + 'ON CONFLICT(player_id) DO NOTHING', ).run(playerId, 'unbound', now(), now()); }, get(playerId) { const row = db.prepare('SELECT * FROM skland_players WHERE player_id = ?').get(playerId); return rowToObject(row); }, /** All rows (admin/debug views). */ list() { return db.prepare('SELECT * FROM skland_players ORDER BY updated_at DESC').all().map(rowToObject); }, /** * Merge fields into a player's row (created when missing). `binding` may be an object — stored as JSON. * @param {string} playerId * @param {Partial<{access_token: string, device_token: string, hg_id: string, cred: string, cred_token: string, * user_id: string, d_id: string, nickname: string, binding: object | null, binding_source: string, status: string, * error: string | null}>} fields */ update(playerId, fields) { this.ensure(playerId); const allowed = ['access_token', 'device_token', 'hg_id', 'cred', 'cred_token', 'user_id', 'd_id', 'nickname', 'binding_source', 'status', 'error']; const sets = []; const values = []; for (const key of allowed) { if (key in fields) { sets.push(`${key} = ?`); values.push(fields[key] ?? null); } } if ('binding' in fields) { sets.push('binding = ?'); values.push(fields.binding == null ? null : JSON.stringify(fields.binding)); } if (!sets.length) return; sets.push('updated_at = ?'); values.push(now(), playerId); db.prepare(`UPDATE skland_players SET ${sets.join(', ')} WHERE player_id = ?`).run(...values); }, /** Remove a player's credentials (解绑). The row stays, marked unbound. */ unbind(playerId) { this.update(playerId, { access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null, user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null, }); }, /** Delete a player's row entirely (e.g. session cleanup). */ remove(playerId) { db.prepare('DELETE FROM skland_players WHERE player_id = ?').run(playerId); }, /** For tests: raw row count. */ count() { return db.prepare('SELECT COUNT(*) AS n FROM skland_players').get().n; }, }; } /** The in-memory fallback (same API; a pre-22.5 Node). Credentials do not survive a restart. */ function createMemoryStore(file) { /** @type {Map>} */ const rows = new Map(); const now = () => Date.now(); const blank = () => ({ player_id: null, access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null, user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null, created_at: 0, updated_at: 0, }); return { file, journalMode() { return 'memory'; }, close() { rows.clear(); }, ensure(playerId) { if (!rows.has(playerId)) rows.set(playerId, { ...blank(), player_id: playerId, created_at: now(), updated_at: now() }); }, get(playerId) { const row = rows.get(playerId); return row ? { ...row } : null; }, list() { return [...rows.values()].map((r) => ({ ...r })); }, update(playerId, fields) { this.ensure(playerId); const row = rows.get(playerId); for (const [key, value] of Object.entries(fields)) { row[key] = key === 'binding' && value != null ? JSON.parse(JSON.stringify(value)) : (value ?? null); } row.updated_at = now(); }, unbind(playerId) { this.update(playerId, { access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null, user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null, }); }, remove(playerId) { rows.delete(playerId); }, count() { return rows.size; }, }; }