实现账号绑定、持久登录与跨设备对局恢复
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
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
This commit is contained in:
1 parent
488db49f11
commit
7c25c89992
62 files changed
+6855
-1005
No files matched your search
@@ -0,0 +1,179 @@
|
||||
// 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; },
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user