Commit 7620d5b7d42d8fe755d0ab9575156bbfa6668c06
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
Showing
4 changed files
with
228 additions
and
11 deletions
admin-auth.test.js
| @@ -4,6 +4,11 @@ | @@ -4,6 +4,11 @@ | ||
| 4 | const test = require("node:test"); | 4 | const test = require("node:test"); |
| 5 | const assert = require("node:assert/strict"); | 5 | const assert = require("node:assert/strict"); |
| 6 | const { spawn } = require("node:child_process"); | 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 | const PORT = 19934 + Math.floor(Math.random() * 1000); | 13 | const PORT = 19934 + Math.floor(Math.random() * 1000); |
| 9 | const BASE = `http://localhost:${PORT}`; | 14 | const BASE = `http://localhost:${PORT}`; |
| @@ -91,3 +96,113 @@ test("logging in with the right password grants a session that unlocks /api/admi | @@ -91,3 +96,113 @@ test("logging in with the right password grants a session that unlocks /api/admi | ||
| 91 | }); | 96 | }); |
| 92 | assert.equal(afterLogout.status, 401); | 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("topup with the same paymentRef twice only credits once (Stripe at-least-on | @@ -75,6 +75,29 @@ test("topup with the same paymentRef twice only credits once (Stripe at-least-on | ||
| 75 | assert.equal(users.get(user.id).creditBalance, 100); | 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 | // "并发发起两次扣积分请求" per T23's acceptance criteria — node:sqlite's | 101 | // "并发发起两次扣积分请求" per T23's acceptance criteria — node:sqlite's |
| 79 | // DatabaseSync is synchronous, so two Promise.all-launched spends can't | 102 | // DatabaseSync is synchronous, so two Promise.all-launched spends can't |
| 80 | // actually interleave mid-transaction within one process; what this proves | 103 | // actually interleave mid-transaction within one process; what this proves |
db.js
| @@ -14,6 +14,14 @@ const { DatabaseSync } = require("node:sqlite"); | @@ -14,6 +14,14 @@ const { DatabaseSync } = require("node:sqlite"); | ||
| 14 | 14 | ||
| 15 | function openDb(filePath) { | 15 | function openDb(filePath) { |
| 16 | const db = new DatabaseSync(filePath); | 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 | db.exec(` | 25 | db.exec(` |
| 18 | CREATE TABLE IF NOT EXISTS users ( | 26 | CREATE TABLE IF NOT EXISTS users ( |
| 19 | id TEXT PRIMARY KEY, | 27 | id TEXT PRIMARY KEY, |
| @@ -47,12 +55,25 @@ function openDb(filePath) { | @@ -47,12 +55,25 @@ function openDb(filePath) { | ||
| 47 | relatedAssetId TEXT, | 55 | relatedAssetId TEXT, |
| 48 | balanceAfter INTEGER NOT NULL, | 56 | balanceAfter INTEGER NOT NULL, |
| 49 | paymentRef TEXT, | 57 | paymentRef TEXT, |
| 58 | + actorUserId TEXT, | ||
| 50 | createdAt INTEGER NOT NULL | 59 | createdAt INTEGER NOT NULL |
| 51 | ); | 60 | ); |
| 52 | CREATE INDEX IF NOT EXISTS idx_credit_ledger_userId ON credit_ledger(userId); | 61 | CREATE INDEX IF NOT EXISTS idx_credit_ledger_userId ON credit_ledger(userId); |
| 53 | CREATE INDEX IF NOT EXISTS idx_credit_ledger_paymentRef ON credit_ledger(paymentRef); | 62 | CREATE INDEX IF NOT EXISTS idx_credit_ledger_paymentRef ON credit_ledger(paymentRef); |
| 54 | CREATE INDEX IF NOT EXISTS idx_credit_ledger_relatedAssetId_reason ON credit_ledger(relatedAssetId, reason); | 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 | return db; | 77 | return db; |
| 57 | } | 78 | } |
| 58 | 79 | ||
| @@ -204,8 +225,8 @@ function rowToLedgerEntry(row) { | @@ -204,8 +225,8 @@ function rowToLedgerEntry(row) { | ||
| 204 | // callers (there are none, by construction). | 225 | // callers (there are none, by construction). |
| 205 | function createCreditLedgerStore(db, users) { | 226 | function createCreditLedgerStore(db, users) { |
| 206 | const insert = db.prepare(` | 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 | const byPaymentRef = db.prepare(`SELECT * FROM credit_ledger WHERE paymentRef = ?`); | 231 | const byPaymentRef = db.prepare(`SELECT * FROM credit_ledger WHERE paymentRef = ?`); |
| 211 | // createdAt alone can tie (millisecond resolution, several writes in one | 232 | // createdAt alone can tie (millisecond resolution, several writes in one |
| @@ -214,9 +235,9 @@ function createCreditLedgerStore(db, users) { | @@ -214,9 +235,9 @@ function createCreditLedgerStore(db, users) { | ||
| 214 | const byUser = db.prepare(`SELECT * FROM credit_ledger WHERE userId = ? ORDER BY createdAt DESC, rowid DESC`); | 235 | const byUser = db.prepare(`SELECT * FROM credit_ledger WHERE userId = ? ORDER BY createdAt DESC, rowid DESC`); |
| 215 | const byRelatedAssetAndReason = db.prepare(`SELECT * FROM credit_ledger WHERE relatedAssetId = ? AND reason = ?`); | 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 | const user = users.get(userId); | 241 | const user = users.get(userId); |
| 221 | if (!user) throw Object.assign(new Error("no such user"), { code: "NO_SUCH_USER" }); | 242 | if (!user) throw Object.assign(new Error("no such user"), { code: "NO_SUCH_USER" }); |
| 222 | const balanceAfter = user.creditBalance + delta; | 243 | const balanceAfter = user.creditBalance + delta; |
| @@ -229,9 +250,10 @@ function createCreditLedgerStore(db, users) { | @@ -229,9 +250,10 @@ function createCreditLedgerStore(db, users) { | ||
| 229 | relatedAssetId, | 250 | relatedAssetId, |
| 230 | balanceAfter, | 251 | balanceAfter, |
| 231 | paymentRef, | 252 | paymentRef, |
| 253 | + actorUserId, | ||
| 232 | createdAt: Date.now(), | 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 | db.prepare(`UPDATE users SET creditBalance = ? WHERE id = ?`).run(balanceAfter, userId); | 257 | db.prepare(`UPDATE users SET creditBalance = ? WHERE id = ?`).run(balanceAfter, userId); |
| 236 | return row; | 258 | return row; |
| 237 | } | 259 | } |
| @@ -275,6 +297,17 @@ function createCreditLedgerStore(db, users) { | @@ -275,6 +297,17 @@ function createCreditLedgerStore(db, users) { | ||
| 275 | return rowToLedgerEntry(applyDelta({ userId, delta: amount, reason, paymentRef })); | 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 | listByUser(userId) { | 311 | listByUser(userId) { |
| 279 | return byUser.all(userId); | 312 | return byUser.all(userId); |
| 280 | }, | 313 | }, |
server.js
| @@ -137,15 +137,30 @@ function parseCookies(req) { | @@ -137,15 +137,30 @@ function parseCookies(req) { | ||
| 137 | return cookies; | 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 | function isAdminAuthed(req) { | 147 | function isAdminAuthed(req) { |
| 141 | const token = parseCookies(req)[ADMIN_SESSION_COOKIE]; | 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 | adminSessions.delete(token); | 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 | // User account login (dev-plan-3.md T19; dressup-dance-room-mvcp.md | 166 | // User account login (dev-plan-3.md T19; dressup-dance-room-mvcp.md |
| @@ -1210,6 +1225,37 @@ http | @@ -1210,6 +1225,37 @@ http | ||
| 1210 | if (garmentsCrud(pathname, req, res)) return; | 1225 | if (garmentsCrud(pathname, req, res)) return; |
| 1211 | if (danceRefsCrud(pathname, req, res)) return; | 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 | if (pathname === "/api/admin/generate" && req.method === "GET") { | 1259 | if (pathname === "/api/admin/generate" && req.method === "GET") { |
| 1214 | res.writeHead(200, { "Content-Type": "application/json" }); | 1260 | res.writeHead(200, { "Content-Type": "application/json" }); |
| 1215 | res.end(JSON.stringify(generationJobs.list())); | 1261 | res.end(JSON.stringify(generationJobs.list())); |