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
+72 -2
View File
@@ -8,8 +8,8 @@
// action but reachable from the dashboard)
import express from "express";
import { getConfigSnapshot, getTierPrices } from "../config.js";
import { snapshotAll } from "../credits.js";
import { getConfigSnapshot, getTierPrices, getTierQuotas } from "../config.js";
import { snapshotAll, computeRemaining, addPurchasedCredits } from "../credits.js";
import { snapshotCache } from "../keysat-client.js";
// snapshotJobs is exported by BOTH ../jobs.js (the in-memory job
// tracker) and ../job-credits.js (the credit-ledger). They return
@@ -47,6 +47,16 @@ import { getHardwareQueueStatus } from "../hardware-queue.js";
import fs from "fs/promises";
import path from "path";
// Human-facing row category derived from the ledger credit-key prefix.
// `user:` → cloud user (recaps.cc), `lic:` → shared license pool,
// `inst:` and legacy bare-installId rows → a single install.
function creditKeyType(key) {
if (typeof key !== "string") return "install";
if (key.startsWith("user:")) return "cloud";
if (key.startsWith("lic:")) return "license";
return "install";
}
export function adminRouter({ dataDir }) {
const router = express.Router();
@@ -58,6 +68,66 @@ export function adminRouter({ dataDir }) {
});
});
// ── Users / credit-balance view ──────────────────────────────────
// Like /usage but enriched for the dashboard's Users tab: each row
// carries a human-facing `type` (derived from the credit-key prefix)
// and a COMPUTED balance (remaining tier credits + purchased top-up),
// since the ledger stores consumed counters, not a remaining number.
router.get("/credits", async (_req, res) => {
const quotas = await getTierQuotas();
const rows = snapshotAll().map((r) => {
const balance = computeRemaining(r, quotas);
return {
...r,
type: creditKeyType(r.credit_key),
remaining: balance.remaining, // tier portion; null = unlimited
purchased: balance.purchased,
total: balance.total, // remaining + purchased; null = unlimited
capped: balance.capped, // "monthly" | "lifetime"
gemini_remaining: balance.gemini_remaining,
};
});
res.json({ count: rows.length, rows });
});
// Grant free credits to one user. Lands in the never-expires
// `purchased_balance` bucket (spent AFTER the tier allotment), so this
// is a pure top-up — it doesn't touch the user's monthly/lifetime
// allowance. Only grants to an EXISTING ledger row: a typo'd key must
// not spawn a ghost row, so we check snapshotAll() first.
router.post("/credits/grant", express.json(), async (req, res) => {
const creditKey =
typeof req.body?.credit_key === "string" ? req.body.credit_key.trim() : "";
const amount = Number(req.body?.amount);
if (!creditKey) {
return res.status(400).json({ error: "credit_key required" });
}
if (!Number.isInteger(amount) || amount <= 0) {
return res.status(400).json({ error: "amount must be a positive integer" });
}
// Fat-finger guard — a manual top-up in the millions is almost
// certainly a typo, not intent. Operator can repeat the grant if
// they genuinely mean to add more.
if (amount > 1_000_000) {
return res.status(400).json({ error: "amount too large (max 1,000,000 per grant)" });
}
const exists = snapshotAll().some((r) => r.credit_key === creditKey);
if (!exists) {
return res.status(404).json({ error: "unknown credit_key" });
}
const newBalance = await addPurchasedCredits({ creditKey, amount });
console.log(
`[admin/credits] manual grant: +${amount} free credit(s) to ${creditKey} ` +
`(new purchased_balance: ${newBalance})`
);
res.json({
ok: true,
credit_key: creditKey,
granted: amount,
purchased_balance: newBalance,
});
});
router.get("/config", async (_req, res) => {
const cfg = await getConfigSnapshot();
const hw = await (await import("../hardware-config.js")).resolveHardwareConfig(cfg);
+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);
});
});