Commit 7620d5b7d42d8fe755d0ab9575156bbfa6668c06

Authored by yibo
1 parent bf831772

Add T30: admin credit adjust + admin auth migration off the shared password

/api/admin/* accepted only the T6 ADMIN_PASSWORD cookie until now —
MVCP M.1/M.10's stated rule is users.role='admin' + a real session, and
M.2's credit_ledger needs a real admin identity to attribute manual
balance corrections to. isAdminAuthed() now accepts either path (the
ADMIN_PASSWORD channel stays as a fallback so nobody's locked out
before any role='admin' account exists by hand — dev-plan-4.md T30's
explicit requirement). New POST /api/admin/credit/adjust wraps the
new creditLedger.adjust() (db.js), which — unlike spend/refund — takes
either sign of delta and records actorUserId (null over the legacy
ADMIN_PASSWORD channel, which has no per-admin identity to attribute
to).

credit_ledger's actorUserId column needed an idempotent ALTER TABLE
guard (no migration framework exists anywhere in this project) since
the table's CREATE TABLE IF NOT EXISTS already shipped in T23 without
it. Also switched db.js to WAL journal mode — surfaced by two processes
legitimately touching the same data.sqlite at once in this session
(the long-running server + a test run) hitting "database is locked" on
the default rollback-journal mode; worth having before this bites in
production too, not just to unblock local test runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
admin-auth.test.js
... ... @@ -4,6 +4,11 @@
4 4 const test = require("node:test");
5 5 const assert = require("node:assert/strict");
6 6 const { spawn } = require("node:child_process");
  7 +// Same data.sqlite the spawned server.js opens (both default to
  8 +// __dirname/data.sqlite, no DB_PATH override below) — used to promote a
  9 +// test account to role='admin' directly, since there's no HTTP endpoint
  10 +// for that by design (dev-plan-4.md T30: "手动置 role='admin'").
  11 +const { users: userStore, creditLedger } = require("./db.js");
7 12  
8 13 const PORT = 19934 + Math.floor(Math.random() * 1000);
9 14 const BASE = `http://localhost:${PORT}`;
... ... @@ -91,3 +96,113 @@ test(&quot;logging in with the right password grants a session that unlocks /api/admi
91 96 });
92 97 assert.equal(afterLogout.status, 401);
93 98 });
  99 +
  100 +// ---- T30: users.role='admin' via the regular user_session cookie is the
  101 +// second accepted path — the ADMIN_PASSWORD flow above must keep working
  102 +// unchanged (already covered above) since role='admin' accounts don't
  103 +// exist yet on a fresh install ----
  104 +
  105 +test("a plain viewer account (no role='admin') does not unlock /api/admin/*", async () => {
  106 + const email = `viewer-${Date.now()}@example.com`;
  107 + const registerRes = await fetch(`${BASE}/api/auth/register`, {
  108 + method: "POST",
  109 + headers: { "Content-Type": "application/json" },
  110 + body: JSON.stringify({ email, password: "correct-horse-battery" }),
  111 + });
  112 + const cookie = sessionCookie(registerRes);
  113 + const res = await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } });
  114 + assert.equal(res.status, 401);
  115 +});
  116 +
  117 +test("promoting an account to role='admin' unlocks /api/admin/*, and POST /api/admin/credit/adjust records that admin as actorUserId", async () => {
  118 + const email = `admin-${Date.now()}@example.com`;
  119 + const registerRes = await fetch(`${BASE}/api/auth/register`, {
  120 + method: "POST",
  121 + headers: { "Content-Type": "application/json" },
  122 + body: JSON.stringify({ email, password: "correct-horse-battery" }),
  123 + });
  124 + const { id: adminUserId } = await registerRes.json();
  125 + const cookie = sessionCookie(registerRes);
  126 +
  127 + // Still 401 before promotion — same session, only the role changed.
  128 + assert.equal((await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } })).status, 401);
  129 +
  130 + userStore.update(adminUserId, { role: "admin" });
  131 + assert.equal((await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } })).status, 200);
  132 +
  133 + // credit/adjust needs a target user to adjust and a positive starting
  134 + // balance to prove the delta actually applied (0 -> negative would also
  135 + // pass a "balance changed" check even if the sign were flipped).
  136 + const targetRes = await fetch(`${BASE}/api/auth/register`, {
  137 + method: "POST",
  138 + headers: { "Content-Type": "application/json" },
  139 + body: JSON.stringify({ email: `adjust-target-${Date.now()}@example.com`, password: "correct-horse-battery" }),
  140 + });
  141 + const { id: targetUserId } = await targetRes.json();
  142 + creditLedger.topup({ userId: targetUserId, amount: 20 });
  143 +
  144 + const adjustRes = await fetch(`${BASE}/api/admin/credit/adjust`, {
  145 + method: "POST",
  146 + headers: { "Content-Type": "application/json", Cookie: cookie },
  147 + body: JSON.stringify({ userId: targetUserId, delta: -5, reason: "admin_adjust" }),
  148 + });
  149 + assert.equal(adjustRes.status, 200);
  150 + const row = await adjustRes.json();
  151 + assert.equal(row.delta, -5);
  152 + assert.equal(row.balanceAfter, 15);
  153 + assert.equal(row.actorUserId, adminUserId);
  154 + assert.equal(userStore.get(targetUserId).creditBalance, 15);
  155 +});
  156 +
  157 +test("credit/adjust over the legacy ADMIN_PASSWORD session (no per-admin identity) still works, with a null actorUserId", async () => {
  158 + const targetRes = await fetch(`${BASE}/api/auth/register`, {
  159 + method: "POST",
  160 + headers: { "Content-Type": "application/json" },
  161 + body: JSON.stringify({ email: `legacy-adjust-target-${Date.now()}@example.com`, password: "correct-horse-battery" }),
  162 + });
  163 + const { id: targetUserId } = await targetRes.json();
  164 +
  165 + const loginRes = await fetch(`${BASE}/api/admin/login`, {
  166 + method: "POST",
  167 + headers: { "Content-Type": "application/json" },
  168 + body: JSON.stringify({ password: ADMIN_PASSWORD }),
  169 + });
  170 + const adminCookie = sessionCookie(loginRes);
  171 +
  172 + const adjustRes = await fetch(`${BASE}/api/admin/credit/adjust`, {
  173 + method: "POST",
  174 + headers: { "Content-Type": "application/json", Cookie: adminCookie },
  175 + body: JSON.stringify({ userId: targetUserId, delta: 10 }),
  176 + });
  177 + assert.equal(adjustRes.status, 200);
  178 + const row = await adjustRes.json();
  179 + assert.equal(row.actorUserId, null);
  180 +
  181 + await fetch(`${BASE}/api/admin/logout`, { method: "POST", headers: { Cookie: adminCookie } });
  182 +});
  183 +
  184 +test("credit/adjust refuses to push a balance negative", async () => {
  185 + const registerRes = await fetch(`${BASE}/api/auth/register`, {
  186 + method: "POST",
  187 + headers: { "Content-Type": "application/json" },
  188 + body: JSON.stringify({ email: `broke-admin-${Date.now()}@example.com`, password: "correct-horse-battery" }),
  189 + });
  190 + const { id: adminUserId } = await registerRes.json();
  191 + const cookie = sessionCookie(registerRes);
  192 + userStore.update(adminUserId, { role: "admin" });
  193 +
  194 + const targetRes = await fetch(`${BASE}/api/auth/register`, {
  195 + method: "POST",
  196 + headers: { "Content-Type": "application/json" },
  197 + body: JSON.stringify({ email: `broke-target-${Date.now()}@example.com`, password: "correct-horse-battery" }),
  198 + });
  199 + const { id: targetUserId } = await targetRes.json();
  200 +
  201 + const res = await fetch(`${BASE}/api/admin/credit/adjust`, {
  202 + method: "POST",
  203 + headers: { "Content-Type": "application/json", Cookie: cookie },
  204 + body: JSON.stringify({ userId: targetUserId, delta: -1 }),
  205 + });
  206 + assert.equal(res.status, 400);
  207 + assert.equal(userStore.get(targetUserId).creditBalance, 0);
  208 +});
... ...
credit-ledger.test.js
... ... @@ -75,6 +75,29 @@ test(&quot;topup with the same paymentRef twice only credits once (Stripe at-least-on
75 75 assert.equal(users.get(user.id).creditBalance, 100);
76 76 });
77 77  
  78 +test("adjust can move a balance either direction and records who did it", () => {
  79 + const { users, creditLedger, user } = setup();
  80 + creditLedger.topup({ userId: user.id, amount: 10 });
  81 +
  82 + const up = creditLedger.adjust({ userId: user.id, delta: 5, actorUserId: "admin-1" });
  83 + assert.equal(up.balanceAfter, 15);
  84 + assert.equal(up.actorUserId, "admin-1");
  85 + assert.equal(up.reason, "admin_adjust");
  86 +
  87 + const down = creditLedger.adjust({ userId: user.id, delta: -3, actorUserId: "admin-1" });
  88 + assert.equal(down.balanceAfter, 12);
  89 + assert.equal(users.get(user.id).creditBalance, 12);
  90 +});
  91 +
  92 +test("adjust refuses to push a balance negative, same as spend", () => {
  93 + const { users, creditLedger, user } = setup();
  94 + assert.throws(
  95 + () => creditLedger.adjust({ userId: user.id, delta: -1 }),
  96 + (err) => err.code === "INSUFFICIENT_BALANCE"
  97 + );
  98 + assert.equal(users.get(user.id).creditBalance, 0);
  99 +});
  100 +
78 101 // "并发发起两次扣积分请求" per T23's acceptance criteria — node:sqlite's
79 102 // DatabaseSync is synchronous, so two Promise.all-launched spends can't
80 103 // actually interleave mid-transaction within one process; what this proves
... ...
... ... @@ -14,6 +14,14 @@ const { DatabaseSync } = require(&quot;node:sqlite&quot;);
14 14  
15 15 function openDb(filePath) {
16 16 const db = new DatabaseSync(filePath);
  17 + // WAL lets readers and a writer coexist instead of the default
  18 + // rollback-journal mode's "any write blocks everyone" — this app now
  19 + // legitimately has multiple processes touching data.sqlite at once (the
  20 + // long-running server plus test runs against the same file in dev), and
  21 + // credit_ledger's writes only get more frequent from here (T24 webhook,
  22 + // T27 spend/refund) — worth having before contention actually bites in
  23 + // production too, not just to unblock local test runs.
  24 + db.exec(`PRAGMA journal_mode = WAL`);
17 25 db.exec(`
18 26 CREATE TABLE IF NOT EXISTS users (
19 27 id TEXT PRIMARY KEY,
... ... @@ -47,12 +55,25 @@ function openDb(filePath) {
47 55 relatedAssetId TEXT,
48 56 balanceAfter INTEGER NOT NULL,
49 57 paymentRef TEXT,
  58 + actorUserId TEXT,
50 59 createdAt INTEGER NOT NULL
51 60 );
52 61 CREATE INDEX IF NOT EXISTS idx_credit_ledger_userId ON credit_ledger(userId);
53 62 CREATE INDEX IF NOT EXISTS idx_credit_ledger_paymentRef ON credit_ledger(paymentRef);
54 63 CREATE INDEX IF NOT EXISTS idx_credit_ledger_relatedAssetId_reason ON credit_ledger(relatedAssetId, reason);
55 64 `);
  65 + // actorUserId was added after credit_ledger's initial CREATE TABLE
  66 + // (T30) — SQLite's ALTER TABLE has no "ADD COLUMN IF NOT EXISTS", and
  67 + // CREATE TABLE IF NOT EXISTS above is a no-op against a table that
  68 + // already exists from before this column existed. No migration
  69 + // framework exists anywhere in this project (JSON-file stores don't
  70 + // need one), so this is the same "idempotent guard, not a migration
  71 + // system" spirit as the rest of the codebase.
  72 + try {
  73 + db.exec(`ALTER TABLE credit_ledger ADD COLUMN actorUserId TEXT`);
  74 + } catch (err) {
  75 + if (!/duplicate column name/.test(err.message)) throw err;
  76 + }
56 77 return db;
57 78 }
58 79  
... ... @@ -204,8 +225,8 @@ function rowToLedgerEntry(row) {
204 225 // callers (there are none, by construction).
205 226 function createCreditLedgerStore(db, users) {
206 227 const insert = db.prepare(`
207   - INSERT INTO credit_ledger (id, userId, delta, reason, relatedAssetId, balanceAfter, paymentRef, createdAt)
208   - VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  228 + INSERT INTO credit_ledger (id, userId, delta, reason, relatedAssetId, balanceAfter, paymentRef, actorUserId, createdAt)
  229 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
209 230 `);
210 231 const byPaymentRef = db.prepare(`SELECT * FROM credit_ledger WHERE paymentRef = ?`);
211 232 // createdAt alone can tie (millisecond resolution, several writes in one
... ... @@ -214,9 +235,9 @@ function createCreditLedgerStore(db, users) {
214 235 const byUser = db.prepare(`SELECT * FROM credit_ledger WHERE userId = ? ORDER BY createdAt DESC, rowid DESC`);
215 236 const byRelatedAssetAndReason = db.prepare(`SELECT * FROM credit_ledger WHERE relatedAssetId = ? AND reason = ?`);
216 237  
217   - // Shared by spend/refund/topup: apply `delta` to users.creditBalance and
218   - // insert the matching ledger row, both inside the caller's transaction.
219   - function applyDelta({ userId, delta, reason, relatedAssetId = null, paymentRef = null }) {
  238 + // Shared by spend/refund/topup/adjust: apply `delta` to users.creditBalance
  239 + // and insert the matching ledger row, both inside the caller's transaction.
  240 + function applyDelta({ userId, delta, reason, relatedAssetId = null, paymentRef = null, actorUserId = null }) {
220 241 const user = users.get(userId);
221 242 if (!user) throw Object.assign(new Error("no such user"), { code: "NO_SUCH_USER" });
222 243 const balanceAfter = user.creditBalance + delta;
... ... @@ -229,9 +250,10 @@ function createCreditLedgerStore(db, users) {
229 250 relatedAssetId,
230 251 balanceAfter,
231 252 paymentRef,
  253 + actorUserId,
232 254 createdAt: Date.now(),
233 255 };
234   - insert.run(row.id, row.userId, row.delta, row.reason, row.relatedAssetId, row.balanceAfter, row.paymentRef, row.createdAt);
  256 + insert.run(row.id, row.userId, row.delta, row.reason, row.relatedAssetId, row.balanceAfter, row.paymentRef, row.actorUserId, row.createdAt);
235 257 db.prepare(`UPDATE users SET creditBalance = ? WHERE id = ?`).run(balanceAfter, userId);
236 258 return row;
237 259 }
... ... @@ -275,6 +297,17 @@ function createCreditLedgerStore(db, users) {
275 297 return rowToLedgerEntry(applyDelta({ userId, delta: amount, reason, paymentRef }));
276 298 });
277 299 },
  300 + // T30: admin-initiated balance correction — delta can be either sign
  301 + // (unlike spend/refund, which each only move money one direction).
  302 + // actorUserId (the admin's own users.id) is what makes this row
  303 + // attributable ("credit_ledger 里能查到是哪个管理员做的"); it's null
  304 + // only when the request came in over the legacy shared ADMIN_PASSWORD
  305 + // channel rather than a real admin account (server.js's
  306 + // adminActorUserId()).
  307 + adjust({ userId, delta, reason = "admin_adjust", actorUserId = null }) {
  308 + if (!delta) throw new Error("adjust delta must be non-zero");
  309 + return inTransaction(() => rowToLedgerEntry(applyDelta({ userId, delta, reason, actorUserId })));
  310 + },
278 311 listByUser(userId) {
279 312 return byUser.all(userId);
280 313 },
... ...
server.js
... ... @@ -137,15 +137,30 @@ function parseCookies(req) {
137 137 return cookies;
138 138 }
139 139  
  140 +// T30: two accepted paths, either is enough — the ADMIN_PASSWORD cookie
  141 +// (T6, unchanged) stays as a fallback channel so nobody's locked out of the
  142 +// backend before any users.role='admin' account has been created by hand;
  143 +// requireUserSession + role='admin' (below, hoisted — same module scope) is
  144 +// the real per-admin identity path, MVCP M.10's stated rule for
  145 +// /api/admin/*. Calls requireUserSession before it's declared further down
  146 +// this file — fine, `function` declarations are fully hoisted.
140 147 function isAdminAuthed(req) {
141 148 const token = parseCookies(req)[ADMIN_SESSION_COOKIE];
142   - if (!token) return false;
143   - const expiresAt = adminSessions.get(token);
144   - if (!expiresAt || expiresAt < Date.now()) {
  149 + if (token) {
  150 + const expiresAt = adminSessions.get(token);
  151 + if (expiresAt && expiresAt >= Date.now()) return true;
145 152 adminSessions.delete(token);
146   - return false;
147 153 }
148   - return true;
  154 + const user = requireUserSession(req);
  155 + return !!(user && user.role === "admin");
  156 +}
  157 +
  158 +// Who to attribute an admin action to (T30's "credit_ledger 里能查到是哪个
  159 +//管理员做的") — null when the request came in over the legacy shared
  160 +// ADMIN_PASSWORD channel, which has no per-admin identity to attribute to.
  161 +function adminActorUserId(req) {
  162 + const user = requireUserSession(req);
  163 + return user && user.role === "admin" ? user.id : null;
149 164 }
150 165  
151 166 // User account login (dev-plan-3.md T19; dressup-dance-room-mvcp.md
... ... @@ -1210,6 +1225,37 @@ http
1210 1225 if (garmentsCrud(pathname, req, res)) return;
1211 1226 if (danceRefsCrud(pathname, req, res)) return;
1212 1227  
  1228 + // T30: manual balance correction, e.g. a support gesture ("sorry about
  1229 + // the failed generation that didn't auto-refund, here's some credits")
  1230 + // or clawing back an abused promo. Always attributable — see
  1231 + // adminActorUserId's doc comment.
  1232 + if (pathname === "/api/admin/credit/adjust" && req.method === "POST") {
  1233 + readJsonBody(req)
  1234 + .then((body) => {
  1235 + const userId = (body.userId || "").trim();
  1236 + const delta = Number(body.delta);
  1237 + if (!userId || !userStore.get(userId) || !Number.isFinite(delta) || delta === 0) {
  1238 + res.writeHead(400, { "Content-Type": "application/json" });
  1239 + res.end(JSON.stringify({ error: "userId (must reference an existing user) and a non-zero numeric delta are required" }));
  1240 + return;
  1241 + }
  1242 + try {
  1243 + const row = creditLedger.adjust({ userId, delta, reason: (body.reason || "admin_adjust").trim(), actorUserId: adminActorUserId(req) });
  1244 + res.writeHead(200, { "Content-Type": "application/json" });
  1245 + res.end(JSON.stringify(row));
  1246 + } catch (err) {
  1247 + const status = err.code === "INSUFFICIENT_BALANCE" ? 400 : 500;
  1248 + res.writeHead(status, { "Content-Type": "application/json" });
  1249 + res.end(JSON.stringify({ error: err.message }));
  1250 + }
  1251 + })
  1252 + .catch(() => {
  1253 + res.writeHead(400, { "Content-Type": "application/json" });
  1254 + res.end(JSON.stringify({ error: "invalid JSON body" }));
  1255 + });
  1256 + return;
  1257 + }
  1258 +
1213 1259 if (pathname === "/api/admin/generate" && req.method === "GET") {
1214 1260 res.writeHead(200, { "Content-Type": "application/json" });
1215 1261 res.end(JSON.stringify(generationJobs.list()));
... ...