Commit 91d455b847d0ec6ad01a08236d82e776fb83b9b1
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
Showing
3 changed files
with
300 additions
and
0 deletions
.gitignore
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 | +}); |