Commit 91d455b847d0ec6ad01a08236d82e776fb83b9b1

Authored by yibo
1 parent 29f24a70

Add T18: SQLite-backed users/sessions store

New db.js opens data.sqlite (node:sqlite's DatabaseSync — a Node 24
built-in, experimental but functional) and creates the users/sessions
tables per dressup-dance-room-mvcp.md M.1/M.10. Zero new npm
dependency: no bcrypt/argon2 (node:crypto's scryptSync covers password
hashing in T19), no better-sqlite3, no package.json needed.

Mirrors store.js's ergonomics (create/get/update, plain JS objects in
and out — booleans and the oauthProviders array marshalled to/from
SQLite's TEXT/INTEGER columns internally) rather than reaching for an
ORM. Exports the openDb/createUsersStore/createSessionsStore factories
alongside a default singleton so tests can point at an isolated tmp
file instead of the real data.sqlite (same pattern
dressup-generate.test.js uses for library.json).

data.sqlite is gitignored under the secrets section, not the "runtime
state" one — unlike library.json it holds real credential material
(password hashes, session tokens), not just regenerable content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Deddn3sM3oQnmvqWSMb4y
.gitignore
1 1 # secrets — never commit
2 2 .env
  3 +data.sqlite*
3 4  
4 5 # runtime state, regenerated by the server, not source
5 6 live-state.json
... ...
db.js 0 → 100644
  1 +// SQLite-backed store for users/sessions (dev-plan-3.md T18; schema:
  2 +// dressup-dance-room-mvcp.md M.1/M.10). Separate from store.js's JSON
  3 +// files on purpose — architecture.md 1.4 flags credential storage as the
  4 +// trigger to move off JSON, other stores (characters/garments/dance-refs/
  5 +// library) are unaffected and stay exactly as they are.
  6 +//
  7 +// node:sqlite (DatabaseSync) is a Node 24 built-in (experimental but
  8 +// functional — confirmed working here) and node:crypto's scryptSync
  9 +// covers password hashing, so this needs zero new npm dependencies and no
  10 +// package.json.
  11 +const path = require("path");
  12 +const crypto = require("crypto");
  13 +const { DatabaseSync } = require("node:sqlite");
  14 +
  15 +function openDb(filePath) {
  16 + const db = new DatabaseSync(filePath);
  17 + db.exec(`
  18 + CREATE TABLE IF NOT EXISTS users (
  19 + id TEXT PRIMARY KEY,
  20 + email TEXT NOT NULL UNIQUE,
  21 + passwordHash TEXT NOT NULL,
  22 + emailVerified INTEGER NOT NULL DEFAULT 0,
  23 + role TEXT NOT NULL DEFAULT 'viewer',
  24 + oauthProviders TEXT NOT NULL DEFAULT '[]',
  25 + nickname TEXT,
  26 + legacyUid TEXT,
  27 + ageConfirmed INTEGER NOT NULL DEFAULT 0,
  28 + ageConfirmedAt INTEGER,
  29 + creditBalance INTEGER NOT NULL DEFAULT 0,
  30 + createdAt INTEGER NOT NULL
  31 + );
  32 + CREATE TABLE IF NOT EXISTS sessions (
  33 + id TEXT PRIMARY KEY,
  34 + userId TEXT NOT NULL REFERENCES users(id),
  35 + kind TEXT NOT NULL,
  36 + token TEXT NOT NULL UNIQUE,
  37 + expiresAt INTEGER NOT NULL,
  38 + createdAt INTEGER NOT NULL
  39 + );
  40 + CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
  41 + CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
  42 + `);
  43 + return db;
  44 +}
  45 +
  46 +function rowToUser(row) {
  47 + if (!row) return null;
  48 + return {
  49 + ...row,
  50 + emailVerified: !!row.emailVerified,
  51 + ageConfirmed: !!row.ageConfirmed,
  52 + oauthProviders: JSON.parse(row.oauthProviders),
  53 + };
  54 +}
  55 +
  56 +// Fields a caller is allowed to patch via users.update() — deliberately a
  57 +// fixed list rather than trusting arbitrary keys, since they get spliced
  58 +// straight into a SQL UPDATE's column list.
  59 +const USER_PATCHABLE_FIELDS = [
  60 + "passwordHash",
  61 + "emailVerified",
  62 + "role",
  63 + "oauthProviders",
  64 + "nickname",
  65 + "legacyUid",
  66 + "ageConfirmed",
  67 + "ageConfirmedAt",
  68 + "creditBalance",
  69 +];
  70 +
  71 +function createUsersStore(db) {
  72 + const insert = db.prepare(`
  73 + INSERT INTO users (id, email, passwordHash, emailVerified, role, oauthProviders, nickname, legacyUid, ageConfirmed, ageConfirmedAt, creditBalance, createdAt)
  74 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  75 + `);
  76 + const byId = db.prepare(`SELECT * FROM users WHERE id = ?`);
  77 + const byEmail = db.prepare(`SELECT * FROM users WHERE email = ?`);
  78 +
  79 + return {
  80 + // Only email/passwordHash are required — everything else (nickname/
  81 + // legacyUid/ageConfirmed/ageConfirmedAt) is how T21 carries an existing
  82 + // anonymous viewer_uid identity onto the new account at registration
  83 + // time, so callers pass what they already have off the cookie.
  84 + create({ email, passwordHash, nickname = null, legacyUid = null, ageConfirmed = false, ageConfirmedAt = null }) {
  85 + const user = {
  86 + id: crypto.randomUUID(),
  87 + email,
  88 + passwordHash,
  89 + emailVerified: false,
  90 + role: "viewer",
  91 + oauthProviders: [],
  92 + nickname,
  93 + legacyUid,
  94 + ageConfirmed,
  95 + ageConfirmedAt,
  96 + creditBalance: 0,
  97 + createdAt: Date.now(),
  98 + };
  99 + try {
  100 + insert.run(
  101 + user.id,
  102 + user.email,
  103 + user.passwordHash,
  104 + user.emailVerified ? 1 : 0,
  105 + user.role,
  106 + JSON.stringify(user.oauthProviders),
  107 + user.nickname,
  108 + user.legacyUid,
  109 + user.ageConfirmed ? 1 : 0,
  110 + user.ageConfirmedAt,
  111 + user.creditBalance,
  112 + user.createdAt
  113 + );
  114 + } catch (err) {
  115 + if (err.code === "ERR_SQLITE_ERROR" && /UNIQUE constraint failed: users\.email/.test(err.message)) {
  116 + const conflict = new Error("email already registered");
  117 + conflict.code = "EMAIL_TAKEN";
  118 + throw conflict;
  119 + }
  120 + throw err;
  121 + }
  122 + return user;
  123 + },
  124 + get(id) {
  125 + return rowToUser(byId.get(id));
  126 + },
  127 + findByEmail(email) {
  128 + return rowToUser(byEmail.get(email));
  129 + },
  130 + update(id, patch) {
  131 + const fields = Object.keys(patch).filter((k) => USER_PATCHABLE_FIELDS.includes(k));
  132 + if (fields.length === 0) return this.get(id);
  133 + const setClause = fields.map((f) => `${f} = ?`).join(", ");
  134 + const values = fields.map((f) => {
  135 + const v = patch[f];
  136 + if (f === "emailVerified" || f === "ageConfirmed") return v ? 1 : 0;
  137 + if (f === "oauthProviders") return JSON.stringify(v);
  138 + return v;
  139 + });
  140 + db.prepare(`UPDATE users SET ${setClause} WHERE id = ?`).run(...values, id);
  141 + return this.get(id);
  142 + },
  143 + };
  144 +}
  145 +
  146 +function createSessionsStore(db) {
  147 + const insert = db.prepare(`INSERT INTO sessions (id, userId, kind, token, expiresAt, createdAt) VALUES (?, ?, ?, ?, ?, ?)`);
  148 + const byToken = db.prepare(`SELECT * FROM sessions WHERE token = ?`);
  149 + const del = db.prepare(`DELETE FROM sessions WHERE token = ?`);
  150 + const delExpired = db.prepare(`DELETE FROM sessions WHERE expiresAt < ?`);
  151 +
  152 + return {
  153 + create({ userId, kind, expiresAt, token = crypto.randomBytes(32).toString("hex") }) {
  154 + const session = { id: crypto.randomUUID(), userId, kind, token, expiresAt, createdAt: Date.now() };
  155 + insert.run(session.id, session.userId, session.kind, session.token, session.expiresAt, session.createdAt);
  156 + return session;
  157 + },
  158 + // Lazily reaps the row if it's expired, same "no separate cron" spirit
  159 + // as the rest of this codebase (e.g. store.js has no TTL sweep either).
  160 + findByToken(token) {
  161 + const row = byToken.get(token);
  162 + if (!row) return null;
  163 + if (row.expiresAt < Date.now()) {
  164 + del.run(token);
  165 + return null;
  166 + }
  167 + return row;
  168 + },
  169 + revoke(token) {
  170 + del.run(token);
  171 + },
  172 + sweepExpired() {
  173 + delExpired.run(Date.now());
  174 + },
  175 + };
  176 +}
  177 +
  178 +const DB_PATH = process.env.DB_PATH || path.join(__dirname, "data.sqlite");
  179 +const db = openDb(DB_PATH);
  180 +const users = createUsersStore(db);
  181 +const sessions = createSessionsStore(db);
  182 +
  183 +module.exports = { db, users, sessions, openDb, createUsersStore, createSessionsStore };
... ...
db.test.js 0 → 100644
  1 +// RED/GREEN check for the SQLite-backed users/sessions store (dev-plan-3.md
  2 +// T18). Run: node --test db.test.js
  3 +const test = require("node:test");
  4 +const assert = require("node:assert/strict");
  5 +const fs = require("node:fs");
  6 +const os = require("node:os");
  7 +const path = require("node:path");
  8 +const { openDb, createUsersStore, createSessionsStore } = require("./db.js");
  9 +
  10 +function tmpDbPath() {
  11 + return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dressup-db-test-")), "data.sqlite");
  12 +}
  13 +
  14 +test("users: create/get/findByEmail round-trip, defaults filled in", () => {
  15 + const db = openDb(tmpDbPath());
  16 + const users = createUsersStore(db);
  17 +
  18 + const user = users.create({ email: "a@example.com", passwordHash: "hash1" });
  19 + assert.ok(user.id);
  20 + assert.equal(user.email, "a@example.com");
  21 + assert.equal(user.emailVerified, false);
  22 + assert.equal(user.role, "viewer");
  23 + assert.deepEqual(user.oauthProviders, []);
  24 + assert.equal(user.creditBalance, 0);
  25 +
  26 + assert.deepEqual(users.get(user.id), user);
  27 + assert.deepEqual(users.findByEmail("a@example.com"), user);
  28 + assert.equal(users.findByEmail("nope@example.com"), null);
  29 + assert.equal(users.get("nonexistent-id"), null);
  30 +});
  31 +
  32 +test("users: duplicate email is rejected as a conflict, not a second row", () => {
  33 + const db = openDb(tmpDbPath());
  34 + const users = createUsersStore(db);
  35 +
  36 + users.create({ email: "dup@example.com", passwordHash: "hash1" });
  37 + assert.throws(() => users.create({ email: "dup@example.com", passwordHash: "hash2" }), (err) => err.code === "EMAIL_TAKEN");
  38 +});
  39 +
  40 +test("users: update() patches only whitelisted fields, ignores the rest", () => {
  41 + const db = openDb(tmpDbPath());
  42 + const users = createUsersStore(db);
  43 + const user = users.create({ email: "b@example.com", passwordHash: "hash1" });
  44 +
  45 + const updated = users.update(user.id, { emailVerified: true, nickname: "小美", id: "should-be-ignored", email: "should-be-ignored@example.com" });
  46 + assert.equal(updated.emailVerified, true);
  47 + assert.equal(updated.nickname, "小美");
  48 + assert.equal(updated.id, user.id);
  49 + assert.equal(updated.email, "b@example.com"); // unchanged original email, patch ignored
  50 +});
  51 +
  52 +test("users: legacyUid/ageConfirmed carry over from an existing anonymous cookie (T21's use case)", () => {
  53 + const db = openDb(tmpDbPath());
  54 + const users = createUsersStore(db);
  55 + const ts = Date.now();
  56 +
  57 + const user = users.create({
  58 + email: "c@example.com",
  59 + passwordHash: "hash1",
  60 + nickname: "老观众",
  61 + legacyUid: "viewer-uid-123",
  62 + ageConfirmed: true,
  63 + ageConfirmedAt: ts,
  64 + });
  65 + assert.equal(user.legacyUid, "viewer-uid-123");
  66 + assert.equal(user.ageConfirmed, true);
  67 + assert.equal(user.ageConfirmedAt, ts);
  68 +});
  69 +
  70 +test("data survives reopening the same file path (simulated process restart)", () => {
  71 + const dbPath = tmpDbPath();
  72 + const db1 = openDb(dbPath);
  73 + const users1 = createUsersStore(db1);
  74 + const created = users1.create({ email: "restart@example.com", passwordHash: "hash1" });
  75 + db1.close();
  76 +
  77 + const db2 = openDb(dbPath);
  78 + const users2 = createUsersStore(db2);
  79 + assert.deepEqual(users2.get(created.id), created);
  80 +});
  81 +
  82 +test("sessions: create/findByToken round-trip", () => {
  83 + const db = openDb(tmpDbPath());
  84 + const users = createUsersStore(db);
  85 + const sessions = createSessionsStore(db);
  86 + const user = users.create({ email: "d@example.com", passwordHash: "hash1" });
  87 +
  88 + const session = sessions.create({ userId: user.id, kind: "session", expiresAt: Date.now() + 100000 });
  89 + assert.ok(session.token);
  90 + assert.equal(sessions.findByToken(session.token).id, session.id);
  91 + assert.equal(sessions.findByToken("nonexistent-token"), null);
  92 +});
  93 +
  94 +test("sessions: an expired token is treated as absent and reaped", () => {
  95 + const db = openDb(tmpDbPath());
  96 + const users = createUsersStore(db);
  97 + const sessions = createSessionsStore(db);
  98 + const user = users.create({ email: "e@example.com", passwordHash: "hash1" });
  99 +
  100 + const session = sessions.create({ userId: user.id, kind: "email_verify", expiresAt: Date.now() - 1000 });
  101 + assert.equal(sessions.findByToken(session.token), null);
  102 + // reaped, not just filtered — a second lookup finds nothing to delete either
  103 + assert.equal(sessions.findByToken(session.token), null);
  104 +});
  105 +
  106 +test("sessions: revoke() logs a session out immediately", () => {
  107 + const db = openDb(tmpDbPath());
  108 + const users = createUsersStore(db);
  109 + const sessions = createSessionsStore(db);
  110 + const user = users.create({ email: "f@example.com", passwordHash: "hash1" });
  111 +
  112 + const session = sessions.create({ userId: user.id, kind: "session", expiresAt: Date.now() + 100000 });
  113 + assert.ok(sessions.findByToken(session.token));
  114 + sessions.revoke(session.token);
  115 + assert.equal(sessions.findByToken(session.token), null);
  116 +});
... ...