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 | +}); | ... | ... |