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
180 lines
7.9 KiB
JavaScript
180 lines
7.9 KiB
JavaScript
// 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<string, any> | 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<string, Record<string, any>>} */
|
|
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; },
|
|
};
|
|
}
|