Add Users dashboard tab with per-user balances and credit grants

New cookie-gated "Users" tab on the operator dashboard: a sortable view
of every credit-ledger row (typed cloud/license/install) with computed
remaining/total balances, key filter, and a per-row "grant free credits"
action.

Endpoints (routes/admin.js):
- GET /admin/credits — snapshotAll() enriched with a type derived from
  the credit-key prefix and a computed balance (computeRemaining against
  live tier quotas), since the ledger stores consumed counters only.
- POST /admin/credits/grant {credit_key, amount} — adds free top-up via
  addPurchasedCredits. Grants land in the never-expires purchased bucket
  (spent after the tier allowance). Guards: positive integer, <=1,000,000,
  and the row must already exist (a typo can't spawn a ghost row).

Admin-only; no /relay/* client contract change. Tests added in
server/test/admin-credits.test.js (mount the real router over HTTP).
Version bumped 0.2.124 -> 0.2.125.
This commit is contained in:
Keysat
2026-06-15 16:25:14 -05:00
parent 00da92a872
commit 798a698132
6 changed files with 373 additions and 6 deletions
+106
View File
@@ -0,0 +1,106 @@
// The /admin/credits read view + /admin/credits/grant action behind the
// Users dashboard tab. Mounts the REAL admin router (sans the cookie
// auth middleware, which lives in index.js) over an ephemeral HTTP
// server so the handler's validation runs for real, not just the
// underlying ledger primitives.
import { test, describe, before, after } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import express from "express";
import { initCredits, setUserTier, addPurchasedCredits } from "../credits.js";
import { adminRouter } from "../routes/admin.js";
let baseUrl;
let server;
const getCredits = async () => {
const r = await fetch(`${baseUrl}/admin/credits`, { cache: "no-store" });
return { status: r.status, body: await r.json() };
};
const grant = async (payload) => {
const r = await fetch(`${baseUrl}/admin/credits/grant`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return { status: r.status, body: await r.json().catch(() => ({})) };
};
const rowFor = (body, key) => body.rows.find((r) => r.credit_key === key);
describe("/admin/credits + /admin/credits/grant", () => {
before(async () => {
await initCredits({ dataDir: mkdtempSync(path.join(tmpdir(), "relay-admincred-")) });
// Seed one row of each ledger-key shape so the type derivation and
// the per-tier balance math are both exercised. Default quotas:
// Core lifetime=10, Pro monthly=50.
await setUserTier({ userId: "alice", tier: "pro" }); // user:alice
await addPurchasedCredits({ creditKey: "inst:bob", amount: 5 }); // Core install
await addPurchasedCredits({ creditKey: "lic:deadbeefdeadbeef", amount: 2 }); // license pool
const app = express();
app.use("/admin", adminRouter({ dataDir: "/tmp" }));
await new Promise((resolve) => { server = app.listen(0, resolve); });
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
after(() => { server?.close(); });
test("GET /credits derives a type from the credit-key prefix", async () => {
const { status, body } = await getCredits();
assert.equal(status, 200);
assert.equal(rowFor(body, "user:alice").type, "cloud");
assert.equal(rowFor(body, "inst:bob").type, "install");
assert.equal(rowFor(body, "lic:deadbeefdeadbeef").type, "license");
});
test("GET /credits computes remaining/total (not raw counters)", async () => {
const { body } = await getCredits();
const alice = rowFor(body, "user:alice"); // Pro, monthly 50, nothing spent
assert.equal(alice.remaining, 50);
assert.equal(alice.purchased, 0);
assert.equal(alice.total, 50);
const bob = rowFor(body, "inst:bob"); // Core lifetime 10 + 5 purchased
assert.equal(bob.remaining, 10);
assert.equal(bob.purchased, 5);
assert.equal(bob.total, 15);
});
test("POST /grant adds to the purchased bucket and returns the new balance", async () => {
const { status, body } = await grant({ credit_key: "inst:bob", amount: 7 });
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.equal(body.granted, 7);
assert.equal(body.purchased_balance, 12); // 5 seeded + 7 granted
// ...and the read view reflects it (purchased 12, total 10 + 12).
const { body: after } = await getCredits();
const bob = rowFor(after, "inst:bob");
assert.equal(bob.purchased, 12);
assert.equal(bob.total, 22);
});
test("POST /grant rejects non-positive / non-integer amounts", async () => {
for (const amount of [0, -5, 1.5, "x"]) {
const { status } = await grant({ credit_key: "inst:bob", amount });
assert.equal(status, 400, `amount=${amount} should be 400`);
}
});
test("POST /grant rejects an absurdly large amount (fat-finger guard)", async () => {
const { status } = await grant({ credit_key: "inst:bob", amount: 2_000_000 });
assert.equal(status, 400);
});
test("POST /grant 400s on missing credit_key, 404s on an unknown one", async () => {
assert.equal((await grant({ amount: 5 })).status, 400);
const unknown = await grant({ credit_key: "inst:nobody", amount: 5 });
assert.equal(unknown.status, 404);
// The unknown key must NOT have been created as a side effect.
const { body } = await getCredits();
assert.equal(rowFor(body, "inst:nobody"), undefined);
});
});