db.js 11.5 KB
// SQLite-backed store for users/sessions (dev-plan-3.md T18; schema:
// dressup-dance-room-mvcp.md M.1/M.10). Separate from store.js's JSON
// files on purpose — architecture.md 1.4 flags credential storage as the
// trigger to move off JSON, other stores (characters/garments/dance-refs/
// library) are unaffected and stay exactly as they are.
//
// node:sqlite (DatabaseSync) is a Node 24 built-in (experimental but
// functional — confirmed working here) and node:crypto's scryptSync
// covers password hashing, so this needs zero new npm dependencies and no
// package.json.
const path = require("path");
const crypto = require("crypto");
const { DatabaseSync } = require("node:sqlite");

function openDb(filePath) {
  const db = new DatabaseSync(filePath);
  db.exec(`
    CREATE TABLE IF NOT EXISTS users (
      id TEXT PRIMARY KEY,
      email TEXT NOT NULL UNIQUE,
      passwordHash TEXT NOT NULL,
      emailVerified INTEGER NOT NULL DEFAULT 0,
      role TEXT NOT NULL DEFAULT 'viewer',
      oauthProviders TEXT NOT NULL DEFAULT '[]',
      nickname TEXT,
      legacyUid TEXT,
      ageConfirmed INTEGER NOT NULL DEFAULT 0,
      ageConfirmedAt INTEGER,
      creditBalance INTEGER NOT NULL DEFAULT 0,
      createdAt INTEGER NOT NULL
    );
    CREATE TABLE IF NOT EXISTS sessions (
      id TEXT PRIMARY KEY,
      userId TEXT NOT NULL REFERENCES users(id),
      kind TEXT NOT NULL,
      token TEXT NOT NULL UNIQUE,
      expiresAt INTEGER NOT NULL,
      createdAt INTEGER NOT NULL
    );
    CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
    CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
    CREATE TABLE IF NOT EXISTS credit_ledger (
      id TEXT PRIMARY KEY,
      userId TEXT NOT NULL REFERENCES users(id),
      delta INTEGER NOT NULL,
      reason TEXT NOT NULL,
      relatedAssetId TEXT,
      balanceAfter INTEGER NOT NULL,
      paymentRef TEXT,
      createdAt INTEGER NOT NULL
    );
    CREATE INDEX IF NOT EXISTS idx_credit_ledger_userId ON credit_ledger(userId);
    CREATE INDEX IF NOT EXISTS idx_credit_ledger_paymentRef ON credit_ledger(paymentRef);
    CREATE INDEX IF NOT EXISTS idx_credit_ledger_relatedAssetId_reason ON credit_ledger(relatedAssetId, reason);
  `);
  return db;
}

function rowToUser(row) {
  if (!row) return null;
  return {
    ...row,
    emailVerified: !!row.emailVerified,
    ageConfirmed: !!row.ageConfirmed,
    oauthProviders: JSON.parse(row.oauthProviders),
  };
}

// Fields a caller is allowed to patch via users.update() — deliberately a
// fixed list rather than trusting arbitrary keys, since they get spliced
// straight into a SQL UPDATE's column list.
const USER_PATCHABLE_FIELDS = [
  "passwordHash",
  "emailVerified",
  "role",
  "oauthProviders",
  "nickname",
  "legacyUid",
  "ageConfirmed",
  "ageConfirmedAt",
  "creditBalance",
];

function createUsersStore(db) {
  const insert = db.prepare(`
    INSERT INTO users (id, email, passwordHash, emailVerified, role, oauthProviders, nickname, legacyUid, ageConfirmed, ageConfirmedAt, creditBalance, createdAt)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `);
  const byId = db.prepare(`SELECT * FROM users WHERE id = ?`);
  const byEmail = db.prepare(`SELECT * FROM users WHERE email = ?`);

  return {
    // Only email/passwordHash are required — everything else (nickname/
    // legacyUid/ageConfirmed/ageConfirmedAt) is how T21 carries an existing
    // anonymous viewer_uid identity onto the new account at registration
    // time, so callers pass what they already have off the cookie.
    create({ email, passwordHash, nickname = null, legacyUid = null, ageConfirmed = false, ageConfirmedAt = null }) {
      const user = {
        id: crypto.randomUUID(),
        email,
        passwordHash,
        emailVerified: false,
        role: "viewer",
        oauthProviders: [],
        nickname,
        legacyUid,
        ageConfirmed,
        ageConfirmedAt,
        creditBalance: 0,
        createdAt: Date.now(),
      };
      try {
        insert.run(
          user.id,
          user.email,
          user.passwordHash,
          user.emailVerified ? 1 : 0,
          user.role,
          JSON.stringify(user.oauthProviders),
          user.nickname,
          user.legacyUid,
          user.ageConfirmed ? 1 : 0,
          user.ageConfirmedAt,
          user.creditBalance,
          user.createdAt
        );
      } catch (err) {
        if (err.code === "ERR_SQLITE_ERROR" && /UNIQUE constraint failed: users\.email/.test(err.message)) {
          const conflict = new Error("email already registered");
          conflict.code = "EMAIL_TAKEN";
          throw conflict;
        }
        throw err;
      }
      return user;
    },
    get(id) {
      return rowToUser(byId.get(id));
    },
    findByEmail(email) {
      return rowToUser(byEmail.get(email));
    },
    update(id, patch) {
      const fields = Object.keys(patch).filter((k) => USER_PATCHABLE_FIELDS.includes(k));
      if (fields.length === 0) return this.get(id);
      const setClause = fields.map((f) => `${f} = ?`).join(", ");
      const values = fields.map((f) => {
        const v = patch[f];
        if (f === "emailVerified" || f === "ageConfirmed") return v ? 1 : 0;
        if (f === "oauthProviders") return JSON.stringify(v);
        return v;
      });
      db.prepare(`UPDATE users SET ${setClause} WHERE id = ?`).run(...values, id);
      return this.get(id);
    },
  };
}

function createSessionsStore(db) {
  const insert = db.prepare(`INSERT INTO sessions (id, userId, kind, token, expiresAt, createdAt) VALUES (?, ?, ?, ?, ?, ?)`);
  const byToken = db.prepare(`SELECT * FROM sessions WHERE token = ?`);
  const del = db.prepare(`DELETE FROM sessions WHERE token = ?`);
  const delExpired = db.prepare(`DELETE FROM sessions WHERE expiresAt < ?`);

  return {
    create({ userId, kind, expiresAt, token = crypto.randomBytes(32).toString("hex") }) {
      const session = { id: crypto.randomUUID(), userId, kind, token, expiresAt, createdAt: Date.now() };
      insert.run(session.id, session.userId, session.kind, session.token, session.expiresAt, session.createdAt);
      return session;
    },
    // Lazily reaps the row if it's expired, same "no separate cron" spirit
    // as the rest of this codebase (e.g. store.js has no TTL sweep either).
    findByToken(token) {
      const row = byToken.get(token);
      if (!row) return null;
      if (row.expiresAt < Date.now()) {
        del.run(token);
        return null;
      }
      return row;
    },
    revoke(token) {
      del.run(token);
    },
    sweepExpired() {
      delExpired.run(Date.now());
    },
  };
}

function rowToLedgerEntry(row) {
  return row || null;
}

// dev-plan-4.md T23: credit_ledger (schema: dressup-dance-room-mvcp.md M.2).
// architecture.md 1.4 flags "扣积分 + 创建生成任务 + 扣减片段配额" as
// needing real atomicity — spend/refund/topup below each read-modify-write
// users.creditBalance inside a SQLite transaction so a crash mid-operation
// can't leave the ledger and the balance disagreeing. node:sqlite's
// DatabaseSync is synchronous and single-connection, so within one process
// there's no interleaving between these calls either way (no `await`
// anywhere in the critical section) — the transaction is defense against a
// mid-function throw leaving a half-applied row, not against concurrent
// callers (there are none, by construction).
function createCreditLedgerStore(db, users) {
  const insert = db.prepare(`
    INSERT INTO credit_ledger (id, userId, delta, reason, relatedAssetId, balanceAfter, paymentRef, createdAt)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  `);
  const byPaymentRef = db.prepare(`SELECT * FROM credit_ledger WHERE paymentRef = ?`);
  // createdAt alone can tie (millisecond resolution, several writes in one
  // tick) — rowid as a tiebreaker keeps ties in insertion order instead of
  // an arbitrary one.
  const byUser = db.prepare(`SELECT * FROM credit_ledger WHERE userId = ? ORDER BY createdAt DESC, rowid DESC`);
  const byRelatedAssetAndReason = db.prepare(`SELECT * FROM credit_ledger WHERE relatedAssetId = ? AND reason = ?`);

  // Shared by spend/refund/topup: apply `delta` to users.creditBalance and
  // insert the matching ledger row, both inside the caller's transaction.
  function applyDelta({ userId, delta, reason, relatedAssetId = null, paymentRef = null }) {
    const user = users.get(userId);
    if (!user) throw Object.assign(new Error("no such user"), { code: "NO_SUCH_USER" });
    const balanceAfter = user.creditBalance + delta;
    if (balanceAfter < 0) throw Object.assign(new Error("insufficient credit balance"), { code: "INSUFFICIENT_BALANCE" });
    const row = {
      id: crypto.randomUUID(),
      userId,
      delta,
      reason,
      relatedAssetId,
      balanceAfter,
      paymentRef,
      createdAt: Date.now(),
    };
    insert.run(row.id, row.userId, row.delta, row.reason, row.relatedAssetId, row.balanceAfter, row.paymentRef, row.createdAt);
    db.prepare(`UPDATE users SET creditBalance = ? WHERE id = ?`).run(balanceAfter, userId);
    return row;
  }

  function inTransaction(fn) {
    db.exec("BEGIN");
    try {
      const result = fn();
      db.exec("COMMIT");
      return result;
    } catch (err) {
      db.exec("ROLLBACK");
      throw err;
    }
  }

  return {
    // Throws INSUFFICIENT_BALANCE (not a negative balance) when the user
    // can't afford `amount`.
    spend({ userId, amount, reason, relatedAssetId = null }) {
      if (amount <= 0) throw new Error("spend amount must be positive");
      return inTransaction(() => rowToLedgerEntry(applyDelta({ userId, delta: -amount, reason, relatedAssetId })));
    },
    refund({ userId, amount, reason, relatedAssetId = null }) {
      if (amount <= 0) throw new Error("refund amount must be positive");
      return inTransaction(() => rowToLedgerEntry(applyDelta({ userId, delta: amount, reason, relatedAssetId })));
    },
    // paymentRef dedup lives here (not spend/refund) because it's the only
    // reason it's ever set (Stripe's event id, T24) — Stripe guarantees
    // at-least-once webhook delivery, so a second delivery of the same
    // event must be a no-op, not a second credit. Returns the *existing*
    // row on a duplicate rather than throwing, since "already applied" is
    // success from the caller's (webhook handler's) point of view.
    topup({ userId, amount, reason = "topup", paymentRef = null }) {
      if (amount <= 0) throw new Error("topup amount must be positive");
      return inTransaction(() => {
        if (paymentRef) {
          const existing = byPaymentRef.get(paymentRef);
          if (existing) return rowToLedgerEntry(existing);
        }
        return rowToLedgerEntry(applyDelta({ userId, delta: amount, reason, paymentRef }));
      });
    },
    listByUser(userId) {
      return byUser.all(userId);
    },
    // T27's async refund-on-generation-failure guard: ensureDanceSchedule's
    // tick can in principle observe the same "generating" -> "failed"
    // transition twice across a crash/restart (see server.js's comment at
    // the call site) — check here rather than trusting the caller to only
    // call refund() once.
    findByRelatedAssetAndReason(relatedAssetId, reason) {
      return byRelatedAssetAndReason.all(relatedAssetId, reason);
    },
  };
}

const DB_PATH = process.env.DB_PATH || path.join(__dirname, "data.sqlite");
const db = openDb(DB_PATH);
const users = createUsersStore(db);
const sessions = createSessionsStore(db);
const creditLedger = createCreditLedgerStore(db, users);

module.exports = { db, users, sessions, creditLedger, openDb, createUsersStore, createSessionsStore, createCreditLedgerStore };