diff --git a/AGENTS.md b/AGENTS.md
index 4577587..5b8618f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -94,7 +94,7 @@ All require a valid `X-Recap-Operator-Key`. Defined in `routes/user-tier.js`.
### `/admin/*` (operator dashboard; cookie-gated)
-`routes/admin.js`: `GET /admin/{usage,config,license-cache,hardware-queue,jobs,jobs-history,job-output/:id,job/:id/details,output-store-stats,output-store-ids,dashboard,dashboard.csv,settings}`, `POST /admin/{quotas,wipe-all,settings/promote-prompt}`, `PUT /admin/settings`, `DELETE /admin/job-outputs`. `routes/admin-test-run.js`: `POST /admin/{test-run,test-run-suite}`. BTCPay setup wizard under `/admin/btcpay/*` (`routes/btcpay-setup.js`).
+`routes/admin.js`: `GET /admin/{usage,credits,config,license-cache,hardware-queue,jobs,jobs-history,job-output/:id,job/:id/details,output-store-stats,output-store-ids,dashboard,dashboard.csv,settings}`, `POST /admin/{quotas,credits/grant,wipe-all,settings/promote-prompt}`, `PUT /admin/settings`, `DELETE /admin/job-outputs`. (`GET /admin/credits` = ledger rows enriched with type + computed balance for the dashboard Users tab; `POST /admin/credits/grant` `{ credit_key, amount }` adds free top-up credits to an existing row.) `routes/admin-test-run.js`: `POST /admin/{test-run,test-run-suite}`. BTCPay setup wizard under `/admin/btcpay/*` (`routes/btcpay-setup.js`).
### `/admin/internal-meetings/*` (cookie-gated; `routes/internal-meetings.js`)
diff --git a/public/dashboard.html b/public/dashboard.html
index 87e74d7..f658c7b 100644
--- a/public/dashboard.html
+++ b/public/dashboard.html
@@ -1044,10 +1044,15 @@
activeTab: (() => {
try {
const saved = localStorage.getItem("recap-relay-active-tab");
- if (saved === "jobs" || saved === "settings" || saved === "overview") return saved;
+ if (saved === "jobs" || saved === "settings" || saved === "overview" || saved === "users") return saved;
} catch {}
return "overview";
})(),
+ // Users-tab state — enriched credit-ledger rows from /admin/credits.
+ creditsData: null,
+ creditsLoading: false,
+ creditsSort: { col: "last_active_at", dir: "desc" },
+ creditsQuery: "",
// Jobs-tab state.
jobsData: null,
jobsLoading: false,
@@ -1176,6 +1181,9 @@
if (state.authed && state.activeTab === "meetings" && !state.meetingsList) {
loadMeetingsList();
}
+ if (state.authed && state.activeTab === "users" && !state.creditsData) {
+ loadCredits();
+ }
// Start the Overview auto-refresh poll on boot regardless of
// current tab — cheap (one fetch every 10s) and ensures the
// tab is current the moment the operator switches to it.
@@ -1460,6 +1468,8 @@
renderSettingsTab();
} else if (state.activeTab === "meetings") {
renderMeetingsTab();
+ } else if (state.activeTab === "users") {
+ renderUsersTab();
} else {
renderDashboard();
}
@@ -1471,6 +1481,7 @@
return '
' +
t("overview", "Overview") +
t("jobs", "Jobs") +
+ t("users", "Users") +
t("meetings", "Internal Meetings") +
t("settings", "Settings") +
'
';
@@ -1561,6 +1572,9 @@
if (tab === "meetings") {
loadMeetingsList();
}
+ if (tab === "users") {
+ loadCredits();
+ }
// Overview tab: re-fetch the dashboard data on EVERY entry (so
// the summaries / errors / perf tables show current state, not
// whatever was last cached) AND start the 10-second auto-refresh
@@ -6662,6 +6676,170 @@
'';
}
+ // ── Users tab ───────────────────────────────────────────────────
+ // A flat view of the credit ledger: every user/install + their
+ // current balance, with a per-row "grant free credits" action.
+ // Balances are COMPUTED server-side (/admin/credits) since the
+ // ledger stores consumed counters, not a remaining number.
+ async function loadCredits() {
+ state.creditsLoading = true;
+ if (state.activeTab === "users") render();
+ try {
+ const r = await fetch("/admin/credits", { cache: "no-store" });
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ const data = await r.json();
+ state.creditsData = Array.isArray(data.rows) ? data.rows : [];
+ } catch (err) {
+ state.creditsData = [];
+ console.warn("loadCredits failed:", err);
+ }
+ state.creditsLoading = false;
+ if (state.activeTab === "users") render();
+ }
+
+ // Credits consumed in the current cycle: Core spends a lifetime
+ // budget, paid tiers spend a monthly one.
+ function usedCount(r) {
+ return (r.tier_snapshot === "pro" || r.tier_snapshot === "max")
+ ? (r.monthly_consumed || 0)
+ : (r.lifetime_consumed || 0);
+ }
+ // null = unlimited tier (no cap). Render it as a word, not a number.
+ function fmtCredits(v) {
+ return v == null ? 'Unlimited' : fmtInt(v);
+ }
+
+ const USERS_COLS = [
+ { key: "credit_key", label: "Key", val: (r) => r.credit_key },
+ { key: "type", label: "Type", val: (r) => r.type },
+ { key: "tier_snapshot", label: "Tier", val: (r) => r.tier_snapshot || "core" },
+ { key: "remaining", label: "Remaining", num: true, val: (r) => r.remaining == null ? Infinity : r.remaining },
+ { key: "purchased", label: "Purchased", num: true, val: (r) => r.purchased || 0 },
+ { key: "total", label: "Total", num: true, val: (r) => r.total == null ? Infinity : r.total },
+ { key: "used", label: "Used (cycle)", num: true, val: (r) => usedCount(r) },
+ { key: "last_active_at", label: "Last active", val: (r) => new Date(r.last_active_at || 0).getTime() },
+ ];
+
+ function sortUsers(col) {
+ const s = state.creditsSort;
+ if (s.col === col) { s.dir = s.dir === "asc" ? "desc" : "asc"; }
+ else { s.col = col; s.dir = (col === "credit_key" || col === "type") ? "asc" : "desc"; }
+ refreshUsersTable();
+ }
+ function filterUsers(v) {
+ state.creditsQuery = v;
+ refreshUsersTable();
+ }
+ // Re-render ONLY the table (sort/filter) so the search box outside
+ // the wrap keeps focus + caret between keystrokes.
+ function refreshUsersTable() {
+ const wrap = document.getElementById("users-table-wrap");
+ if (wrap) wrap.innerHTML = usersTableHtml();
+ }
+
+ function usersTableHtml() {
+ const rows = (state.creditsData || []).slice();
+ const q = (state.creditsQuery || "").trim().toLowerCase();
+ const filtered = q
+ ? rows.filter((r) => (r.credit_key || "").toLowerCase().includes(q))
+ : rows;
+ if (filtered.length === 0) {
+ return '' + (q ? "No users match “" + esc(q) + "”." : "No users yet.") + '
';
+ }
+ const { col, dir } = state.creditsSort;
+ const colDef = USERS_COLS.find((c) => c.key === col) || USERS_COLS[0];
+ const mul = dir === "asc" ? 1 : -1;
+ filtered.sort((a, b) => {
+ const av = colDef.val(a), bv = colDef.val(b);
+ if (typeof av === "number" && typeof bv === "number") return (av - bv) * mul;
+ return String(av).localeCompare(String(bv)) * mul;
+ });
+
+ const thead = USERS_COLS.map((c) => {
+ const ind = c.key === col ? (dir === "asc" ? " ▲" : " ▼") : "";
+ return ''
+ + esc(c.label) + '' + ind + ' | ';
+ }).join("") + 'Grant | ';
+
+ const tbody = filtered.map((r) => {
+ const typeBadge = ''
+ + esc(r.type) + '';
+ const grantCell = ''
+ + ''
+ + ''
+ + '
';
+ return ''
+ + '' + esc(shortId(r.credit_key)) + ' | '
+ + '' + typeBadge + ' | '
+ + '' + tierPill(r.tier_snapshot) + ' | '
+ + '' + fmtCredits(r.remaining) + ' | '
+ + '' + fmtInt(r.purchased || 0) + ' | '
+ + '' + fmtCredits(r.total) + ' | '
+ + '' + fmtInt(usedCount(r)) + ' | '
+ + '' + esc(fmtTs(r.last_active_at)) + ' | '
+ + '' + grantCell + ' | '
+ + '
';
+ }).join("");
+
+ return '' + thead + '
' + tbody + '
';
+ }
+
+ async function grantCredits(btn) {
+ const key = btn.dataset.key;
+ const input = btn.parentElement.querySelector(".grant-amt");
+ const amount = parseInt(input && input.value, 10);
+ if (!Number.isInteger(amount) || amount <= 0) {
+ alert("Enter a positive whole number of credits.");
+ return;
+ }
+ if (!confirm("Grant " + amount + " free credit(s) to " + key + "?\n\nThese never expire and are spent AFTER the user's tier allowance.")) {
+ return;
+ }
+ btn.disabled = true;
+ try {
+ const r = await fetch("/admin/credits/grant", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ credit_key: key, amount }),
+ });
+ const data = await r.json().catch(() => ({}));
+ if (!r.ok) throw new Error(data.error || ("HTTP " + r.status));
+ // Refetch enriched rows so Remaining/Total/Purchased reflect the grant.
+ await loadCredits();
+ } catch (err) {
+ alert("Grant failed: " + (err?.message || err));
+ btn.disabled = false;
+ }
+ }
+
+ function renderUsersTab() {
+ const data = state.creditsData;
+ let body;
+ if (data == null) {
+ body = '' + (state.creditsLoading ? "Loading users…" : "No data.") + '
';
+ } else {
+ const counts = data.reduce((acc, r) => { acc[r.type] = (acc[r.type] || 0) + 1; return acc; }, {});
+ const summary = ''
+ + fmtInt(data.length) + ' user(s) — '
+ + (counts.cloud || 0) + ' cloud · ' + (counts.license || 0) + ' license · ' + (counts.install || 0) + ' install. '
+ + 'Grants land in the never-expires top-up bucket (spent after the tier allowance).'
+ + '
';
+ const search = '';
+ body = summary + search + '' + usersTableHtml() + '
';
+ }
+ root.innerHTML =
+ 'Recap Relay — Operator Dashboard
' +
+ tabsHtml() +
+ '' +
+ body +
+ '
';
+ }
+
function table(headers, rows) {
if (rows.length === 0) return 'No data in this range.
';
const thead = headers.map(h => '' + esc(h) + ' | ').join("");
diff --git a/server/routes/admin.js b/server/routes/admin.js
index 5a76fca..9c96088 100644
--- a/server/routes/admin.js
+++ b/server/routes/admin.js
@@ -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);
diff --git a/server/test/admin-credits.test.js b/server/test/admin-credits.test.js
new file mode 100644
index 0000000..f6b2cf2
--- /dev/null
+++ b/server/test/admin-credits.test.js
@@ -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);
+ });
+});
diff --git a/startos/versions/index.ts b/startos/versions/index.ts
index 62995f7..4e6d213 100644
--- a/startos/versions/index.ts
+++ b/startos/versions/index.ts
@@ -125,8 +125,9 @@ import { v_0_2_121 } from './v0.2.121'
import { v_0_2_122 } from './v0.2.122'
import { v_0_2_123 } from './v0.2.123'
import { v_0_2_124 } from './v0.2.124'
+import { v_0_2_125 } from './v0.2.125'
export const versionGraph = VersionGraph.of({
- current: v_0_2_124,
- other: [v_0_2_123, v_0_2_122, v_0_2_121, v_0_2_120, v_0_2_119, v_0_2_118, v_0_2_117, v_0_2_116, v_0_2_115, v_0_2_114, v_0_2_113, v_0_2_112, v_0_2_111, v_0_2_110, v_0_2_109, v_0_2_108, v_0_2_107, v_0_2_106, v_0_2_105, v_0_2_104, v_0_2_103, v_0_2_102, v_0_2_101, v_0_2_100, v_0_2_99, v_0_2_98, v_0_2_97, v_0_2_96, v_0_2_95, v_0_2_94, v_0_2_93, v_0_2_92, v_0_2_91, v_0_2_90, v_0_2_89, v_0_2_88, v_0_2_87, v_0_2_86, v_0_2_85, v_0_2_84, v_0_2_83, v_0_2_82, v_0_2_81, v_0_2_80, v_0_2_79, v_0_2_78, v_0_2_77, v_0_2_76, v_0_2_75, v_0_2_74, v_0_2_73, v_0_2_72, v_0_2_71, v_0_2_70, v_0_2_69, v_0_2_68, v_0_2_67, v_0_2_66, v_0_2_65, v_0_2_64, v_0_2_63, v_0_2_62, v_0_2_61, v_0_2_60, v_0_2_59, v_0_2_58, v_0_2_57, v_0_2_56, v_0_2_55, v_0_2_54, v_0_2_53, v_0_2_52, v_0_2_51, v_0_2_50, v_0_2_49, v_0_2_48, v_0_2_47, v_0_2_46, v_0_2_45, v_0_2_44, v_0_2_43, v_0_2_42, v_0_2_41, v_0_2_40, v_0_2_39, v_0_2_38, v_0_2_37, v_0_2_36, v_0_2_35, v_0_2_34, v_0_2_33, v_0_2_32, v_0_2_31, v_0_2_30, v_0_2_29, v_0_2_28, v_0_2_27, v_0_2_26, v_0_2_25, v_0_2_24, v_0_2_23, v_0_2_22, v_0_2_21, v_0_2_20, v_0_2_19, v_0_2_18, v_0_2_17, v_0_2_16, v_0_2_15, v_0_2_14, v_0_2_13, v_0_2_12, v_0_2_11, v_0_2_10, v_0_2_9, v_0_2_8, v_0_2_7, v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_0],
+ current: v_0_2_125,
+ other: [v_0_2_124, v_0_2_123, v_0_2_122, v_0_2_121, v_0_2_120, v_0_2_119, v_0_2_118, v_0_2_117, v_0_2_116, v_0_2_115, v_0_2_114, v_0_2_113, v_0_2_112, v_0_2_111, v_0_2_110, v_0_2_109, v_0_2_108, v_0_2_107, v_0_2_106, v_0_2_105, v_0_2_104, v_0_2_103, v_0_2_102, v_0_2_101, v_0_2_100, v_0_2_99, v_0_2_98, v_0_2_97, v_0_2_96, v_0_2_95, v_0_2_94, v_0_2_93, v_0_2_92, v_0_2_91, v_0_2_90, v_0_2_89, v_0_2_88, v_0_2_87, v_0_2_86, v_0_2_85, v_0_2_84, v_0_2_83, v_0_2_82, v_0_2_81, v_0_2_80, v_0_2_79, v_0_2_78, v_0_2_77, v_0_2_76, v_0_2_75, v_0_2_74, v_0_2_73, v_0_2_72, v_0_2_71, v_0_2_70, v_0_2_69, v_0_2_68, v_0_2_67, v_0_2_66, v_0_2_65, v_0_2_64, v_0_2_63, v_0_2_62, v_0_2_61, v_0_2_60, v_0_2_59, v_0_2_58, v_0_2_57, v_0_2_56, v_0_2_55, v_0_2_54, v_0_2_53, v_0_2_52, v_0_2_51, v_0_2_50, v_0_2_49, v_0_2_48, v_0_2_47, v_0_2_46, v_0_2_45, v_0_2_44, v_0_2_43, v_0_2_42, v_0_2_41, v_0_2_40, v_0_2_39, v_0_2_38, v_0_2_37, v_0_2_36, v_0_2_35, v_0_2_34, v_0_2_33, v_0_2_32, v_0_2_31, v_0_2_30, v_0_2_29, v_0_2_28, v_0_2_27, v_0_2_26, v_0_2_25, v_0_2_24, v_0_2_23, v_0_2_22, v_0_2_21, v_0_2_20, v_0_2_19, v_0_2_18, v_0_2_17, v_0_2_16, v_0_2_15, v_0_2_14, v_0_2_13, v_0_2_12, v_0_2_11, v_0_2_10, v_0_2_9, v_0_2_8, v_0_2_7, v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_0],
})
diff --git a/startos/versions/v0.2.125.ts b/startos/versions/v0.2.125.ts
new file mode 100644
index 0000000..1dfe44d
--- /dev/null
+++ b/startos/versions/v0.2.125.ts
@@ -0,0 +1,12 @@
+import { VersionInfo } from '@start9labs/start-sdk'
+
+export const v_0_2_125 = VersionInfo.of({
+ version: '0.2.125:0',
+ releaseNotes: {
+ en_US: 'Add Users tab to the operator dashboard: per-user credit balances with a grant-free-credits action',
+ },
+ migrations: {
+ up: async ({ effects }) => {},
+ down: async ({ effects }) => {},
+ },
+})