From 798a698132f79e830f2fa198eb22a28dc5585f9c Mon Sep 17 00:00:00 2001 From: Keysat Date: Mon, 15 Jun 2026 16:25:14 -0500 Subject: [PATCH] Add Users dashboard tab with per-user balances and credit grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 2 +- public/dashboard.html | 180 +++++++++++++++++++++++++++++- server/routes/admin.js | 74 +++++++++++- server/test/admin-credits.test.js | 106 ++++++++++++++++++ startos/versions/index.ts | 5 +- startos/versions/v0.2.125.ts | 12 ++ 6 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 server/test/admin-credits.test.js create mode 100644 startos/versions/v0.2.125.ts 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 }) => {}, + }, +})