Compare commits
14 Commits
0b90120b72
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d0d1b2dd2 | |||
| 238689ddcc | |||
| 798a698132 | |||
| 00da92a872 | |||
| b10399819b | |||
| e4c6c30ee3 | |||
| 3e33728013 | |||
| 693d72431b | |||
| da1bba2e6b | |||
| cbd9748a79 | |||
| 54ddaffced | |||
| 3a601e166a | |||
| d2caa98248 | |||
| 8ad7c54da4 |
+8
-2
@@ -26,8 +26,14 @@ ytdlp-cache/
|
||||
|
||||
# Local dev secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Claude Code state (worktrees, plans, etc.) — but commit the lazy-load
|
||||
# rule symlinks under .claude/rules/ (they point into docs/guides/).
|
||||
# Claude Code — deny by default (worktrees, plans, local settings stay out),
|
||||
# allow-list shared wiring (see standards/portability.md).
|
||||
.claude/*
|
||||
!.claude/rules/
|
||||
!.claude/agents/
|
||||
!.claude/commands/
|
||||
!.claude/skills/
|
||||
!.claude/settings.json
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
Operator-side, credit-metered service that sits in front of Gemini and the operator's local AI hardware ("Spark Control": Parakeet ASR, Sortformer diarization, TitaNet voice embeddings, a vLLM/Gemma analyze endpoint). The Recaps app (`../recap`) is the client; this repo owns transcription/diarization/analysis routing, the cloud Pro/Max tier + expiry, self-serve billing settlement, and the **internal-meetings** feature (upload audio → transcribe → diarize → cluster → analyze → polish → operator dashboard). **Private. Ships to the operator's own Start9 box via `make install` only — NEVER to the public registry.**
|
||||
|
||||
> **Inbox check:** At session start, if `~/Projects/standards/INBOX.md` exists, scan it for
|
||||
> items tagged `(recap-relay)` and surface them before proposing next steps; triage with `/triage`.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Server**: Node.js (`type: module`, ES modules). Same dev box as the app (`v25.6.1`); container runtime is whatever the `Dockerfile` pins.
|
||||
- **HTTP**: `express` + `multer` (audio upload). Admin routes under `/admin/*` behind an admin-session-cookie gate; relay-to-relay routes under `/relay/*` behind the operator key.
|
||||
- **HTTP**: `express` + `multer` (audio upload). Admin routes under `/admin/*` behind an admin-session-cookie gate. `/relay/*` uses per-call header auth — install-id/license, or operator-key + user-id for the cloud control plane (a few routes like `health`/`policy`/`capabilities` are public). See the Auth model under Endpoints. `cors()` is scoped to `/relay/*` only.
|
||||
- **Dashboard**: `public/dashboard.html` — single-file vanilla JS, render-string-into-innerHTML, same shape as the app's `index.html`.
|
||||
- **Packaging**: `@start9labs/start-sdk` under `startos/` — version graph at `startos/versions/index.ts`.
|
||||
- **Storage**: filesystem under the StartOS data dir (`/data`). Internal meetings persist as `/data/internal-meetings/<id>.json`. No SQLite here.
|
||||
- **Storage**: filesystem under the StartOS data dir (`/data`). No SQLite — flat JSON files: credit ledger `/data/credits.json`, payment-webhook dedup `/data/processed-webhooks.json`, internal meetings `/data/internal-meetings/<id>.json`.
|
||||
- **Upstreams**: Gemini (`@google/genai`); operator hardware via "Spark Control" HTTP (Parakeet transcribe, `/api/audio/diarize-chunk` for Sortformer+TitaNet, a vLLM/Gemma OpenAI-shape analyze endpoint).
|
||||
|
||||
## Commands
|
||||
@@ -43,8 +46,11 @@ server/
|
||||
chunked-analyze.js windowed analyze (planWindowsByDuration, runPipelinedAnalysis, …)
|
||||
config.js getConfigSnapshot() + relay_* config defaults
|
||||
hardware-config.js resolveHardwareConfig() → Spark Control endpoint discovery
|
||||
test/ node --test files (speaker-clustering, meeting-speaker-edits, credits)
|
||||
public/dashboard.html operator dashboard (meetings detail view + speaker tools)
|
||||
safe-url.js SSRF guard: assertPublicHttpUrl + safeFetch for caller-supplied URLs
|
||||
webhook-dedup.js persistent payment-webhook dedup (BTCPay + Zaprite share it);
|
||||
initWebhookDedup/isWebhookProcessed/markWebhookProcessed
|
||||
test/ node --test *.test.js (speaker tools, billing/credits, SSRF, path-traversal, …)
|
||||
public/dashboard.html operator dashboard (Overview / Jobs / Users / Internal Meetings / Settings)
|
||||
startos/versions/<vN>.ts one file per version + index.ts graph
|
||||
docs/issues-backlog.md detailed issue log
|
||||
docs/guides/internal-meetings.md diarization / speaker subsystem guide (path-scoped; lazy-loads via .claude/rules/)
|
||||
@@ -58,7 +64,7 @@ All routes mount in `server/index.js`. Public paths sit under `/relay/*`; operat
|
||||
|
||||
- **`X-Recap-Operator-Key`** + **`X-Recap-User-Id`** → "cloud" path. The Recaps cloud server (`recaps.cc`) authenticates once with a shared operator key (`relay_cloud_operator_key`) and names the acting user. Credit pool keyed `user:<id>`, tier comes from the relay's stored row, NOT a per-user license. See `server/identity.js`.
|
||||
- **`X-Recap-Install-Id`** (+ optional `Authorization: <license>`) → "license" path. Self-hosted installs and the operator's single-mode app. Credits/tier come from the resolved Keysat license + install id.
|
||||
- **Admin session cookie** → `/admin/*`. Cookie issued by `POST /admin/login`; `/admin/login` and `/admin/status` are exempt inside `setupAdminAuthMiddleware`.
|
||||
- **Admin session cookie** → `/admin/*`. Cookie issued by `POST /admin/login`; `/admin/login`, `/admin/status`, and `/admin/btcpay/callback` are exempt inside `setupAdminAuthMiddleware`.
|
||||
- **Webhook signature** → `POST /relay/btcpay/webhook` validates `BTCPay-Sig` against `relay_btcpay_webhook_secret`. Zaprite's webhook re-fetches the order through the Zaprite API to verify, so no shared-secret signing.
|
||||
- **`X-Recap-Job-Id`** is a billing key, not auth: the first call with a given id charges one credit; later calls with the same id are free (so transcribe + analyze for one summary = one credit total).
|
||||
|
||||
@@ -90,7 +96,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`)
|
||||
|
||||
@@ -120,6 +126,7 @@ this. When unsure whether a change is contract-affecting, assume it is and check
|
||||
|
||||
- **Before editing the internal-meetings / diarization / speaker subsystem, read `docs/guides/internal-meetings.md`** — the diarize→cluster→polish pipeline, the four-places speaker-label sync rule, the clustering-threshold knobs, and the post-hoc speaker-edit (merge / recluster / repolish) semantics live there. Scoped to `server/{speaker-clustering,post-cluster-polish,meeting-extras,meeting-speaker-edits,chunked-analyze}.js`, `server/routes/internal-meetings.js`, `server/backends/hardware.js`.
|
||||
- **Doc layout**: `AGENTS.md` is canonical; `CLAUDE.md` is a symlink to it (don't overwrite it). Subsystem guides are real files in `docs/guides/<topic>.md` (with `paths:` frontmatter); `.claude/rules/<topic>.md` are relative symlinks into them (`.gitignore` carves out `!.claude/rules/` so the symlinks commit). New guide = add `docs/guides/<topic>.md`, symlink it from `.claude/rules/`, add an index line above.
|
||||
- **Fetching a caller-supplied URL? Go through `server/safe-url.js`** (`safeFetch` / `assertPublicHttpUrl`) — the SSRF guard that rejects non-http(s) schemes and hosts resolving to private/loopback/link-local/reserved ranges, and re-validates every redirect hop. `downloadDirect` (the transcribe-url/summarize-url/admin-test-run download path) already routes through it; never raw-`fetch` an untrusted URL. Calls to the operator's OWN hardware/LAN use `lan-fetch.js` instead — those URLs are config-set and intentionally private.
|
||||
- **`make install` correctness**: see [Always]. Honest reports; failing test/build is a failure. Comments explain WHY. Write tests alongside (`server/test/*.test.js`, `node --test`).
|
||||
|
||||
## Always
|
||||
@@ -136,36 +143,14 @@ this. When unsure whether a change is contract-affecting, assume it is and check
|
||||
- **Never edit a `startos/versions/<v>.ts` that's already been built/installed** — add a new version file.
|
||||
- **Don't push to GitHub by default** — remote is self-hosted Gitea.
|
||||
|
||||
## Current state — full eval done (2026-06-13); findings triaged below
|
||||
## Current state — Users tab + webhook-dedup/P2 batch landed (2026-06-15)
|
||||
|
||||
- **Box, local tree, and git aligned at relay `0.2.124`** (app at `0.2.155`). `startos/versions/index.ts` `current: v_0_2_124`. Git history is local-only (no remote). Working tree is clean apart from an untracked `server/package-lock.json` left by the eval's `npm install` — a generated artifact, intentionally NOT committed.
|
||||
- **Full independent evaluation run 2026-06-13** (evaluator + security-auditor + exerciser + doc-auditor + start9-spec-checker). Report committed at `EVALUATION.md` (`b08e836`); it's overwritten in place each run so re-running gives a reviewable diff. 47/47 tests still pass; server boots clean. Findings triaged into the three buckets below.
|
||||
- **Post-hoc speaker tools remain live**: `meeting-speaker-edits.js` (merge / recluster / repolish + backfill) + the `PATCH/POST /admin/internal-meetings/:id/{merge-speakers,recluster,repolish}` routes; dashboard exposes the controls.
|
||||
|
||||
### Work queue — P0/P1 (fix first)
|
||||
|
||||
1. **SSRF on `/relay/transcribe-url` + `/relay/summarize-url`** — `downloadDirect` (`server/routes/transcribe-url.js:99-159`) fetches any caller `media_url` with redirect-follow and no scheme/host allowlist; reachable via a self-chosen `X-Recap-Install-Id` (effectively unauthenticated). Reject non-http(s) + private/loopback/link-local hosts; re-validate per redirect hop. *(evaluator + security-auditor + exerciser — headline risk)*
|
||||
2. **Billing money-leak: early/mid-cycle renewal resets `monthly_consumed = 0`** — `extendUserTier` → `setUserTier` (`server/credits.js:770→641`) hands back a full monthly allotment for free; a webhook double-fire across restart compounds it. Preserve `monthly_consumed`/`last_renewal_at`, roll only on a true period boundary; add a money-path test (`tier-expiry.test.js:63` asserts only the date today). *(evaluator)*
|
||||
3. **`multer@1.4.5-lts.2` DoS CVEs** (CVE-2025-47944/47935/48997/7338) — malformed multipart can crash the process. Upgrade to `^2.0.1`, re-test the `file` upload field (`server/package.json:15`). *(security-auditor + exerciser)*
|
||||
|
||||
### Known debt — P2 (accepted for now; fix opportunistically)
|
||||
|
||||
- Path traversal on internal-meetings `:id` (admin-gated): validate `^[A-Za-z0-9_-]+$` before `path.join` — `routes/internal-meetings.js:84,91,242` (`output-store.js:52` shows the pattern). *(security-auditor + exerciser)*
|
||||
- Non-constant-time operator-key compare (`!==`) on `relay_cloud_operator_key` — `server/identity.js:43,84`; use `timingSafeEqual` like the admin path. *(evaluator + security-auditor)*
|
||||
- In-memory webhook dedup Set lost on restart → double-credit/double-extend — `routes/credits.js:63`, `zaprite-webhook.js:27`; persist processed invoice/order ids. *(security-auditor)*
|
||||
- Malformed JSON body → full Node stack trace (FS paths) — add an Express `entity.parse.failed` → JSON-400 handler. *(exerciser)*
|
||||
- BTCPay declared `optional:false`/`kind:'running'` despite "optional" comments → StartOS won't start the relay without BTCPay co-installed — `startos/manifest/index.ts:38-49`, `startos/dependencies.ts`. Decide, then make manifest + dependencies + comment agree. *(start9-spec-checker)*
|
||||
- No money-path unit tests (`commitCredit`/`refundCredit`/`applyTierPromotion`/`planBackend`/grant handlers) — why the P1 billing bug ships green. *(evaluator)*
|
||||
- `routes/internal-meetings.js` is 2225 lines; extract the MD/HTML formatters + storage/backfill layer. *(evaluator)*
|
||||
- Fully-open `cors()` incl. `/admin/*` — scope origins — `server/index.js:54`. *(evaluator)*
|
||||
- Doc drift: AGENTS.md "Stack" line mis-states `/relay/*` auth (most routes are per-call header auth; only `routes/user-tier.js` needs the operator key); the admin-exempt list omits `/admin/btcpay/callback` (`admin-auth.js:70`). *(doc-auditor)*
|
||||
|
||||
### Deferred — P3+ (later decision or bulk cleanup)
|
||||
|
||||
- Security hardening: no `/relay/*` rate limiting; container likely runs as root (entrypoint `chown`s uid 1001 but no `USER` directive); dashboard `innerHTML` stored-XSS surface; `lan-fetch` TLS verify off (admin-set URL only); debug/error fields leaked to clients. *(security-auditor + evaluator)*
|
||||
- Packaging/ops: prune the 126 `startos/versions/*.ts` files; pin `yt-dlp` in the Dockerfile; Dockerfile per-subdir `COPY` footgun; manifest polish (SPDX license, `docsUrls`, real repo URLs, icon format); no `README.md` (blocks public-registry submission only — moot for this private box). *(start9-spec-checker + evaluator)*
|
||||
- `/relay/health` reports stale `0.2.11` — `server/package.json` never bumped past 0.2.11; bump it to track the StartOS version. *(exerciser + doc-auditor)*
|
||||
- Doc fixes (bulk): the `test/` layout lists 3 of 6 files; `server/index.js:3-6` "two endpoints" header comment is stale; `POST /admin/logout` undocumented. *(doc-auditor)*
|
||||
- Untested blind spot: the live upload → merge → recluster → repolish pipeline (admin-gated + needs Spark Control) has only unit coverage; the dependency audit ran offline — re-run `npm audit`/`osv-scanner` with network to confirm the multer finding and catch transitive CVEs. *(all agents)*
|
||||
|
||||
**Pre-existing backlog** (separate from the eval): speaker-tool follow-ups and the empty-analysis-section issue — see `ROADMAP.md` / `docs/issues-backlog.md`.
|
||||
- **Box, local tree, git aligned at relay `0.2.126`** (app `0.2.155`); `current: v_0_2_126`. Gitea remote `origin` (`ssh://git@immense-voyage.local:59916/grant/recap-relay.git`); `master` tracks `origin/master`. Working tree clean. **Suite green at 79 tests** (`cd server && npm test`); server boots clean.
|
||||
- **Users dashboard tab** (`0.2.125`): new cookie-gated tab — every credit-ledger row (typed cloud/license/install) with computed remaining/total balances, key filter, and a per-row "grant free credits" action. `GET /admin/credits` (enriched read) + `POST /admin/credits/grant {credit_key, amount}` (free top-up via `addPurchasedCredits`, guards: positive int ≤1M, must be an existing row). Admin-only; no `../recap` contract change.
|
||||
- **Webhook dedup now persistent** (`0.2.126`): new `server/webhook-dedup.js` (JSON store at `/data/processed-webhooks.json`, atomic writes, 180-day prune) replaces the in-memory Sets in `routes/credits.js` + `zaprite-webhook.js` (and the rescan path) — a duplicate delivery straddling a restart can no longer double-credit/double-extend. Keys namespaced `<storeId>|<invoiceId>` vs `zaprite:<orderId>`.
|
||||
- **BTCPay is REQUIRED** (operator decision, 2026-06-15): config was already `optional:false`/`kind:'running'`; corrected the contradictory "optional" comment in `startos/manifest/index.ts`. It's the only paid rail, so the relay shouldn't run without it.
|
||||
- **CORS scoped to `/relay/*`** (`index.js`) — off `/admin/*` + dashboard (same-origin). Plus money-path unit tests (`commitCredit`/`refundCredit`/`applyTierPromotion`) and the two AGENTS.md auth-doc drift fixes.
|
||||
- **Next (open P2 / deferred):**
|
||||
1. Split the 2225-line `routes/internal-meetings.js` — **deferred as likely overkill** for a private service; do only if it becomes painful to work in.
|
||||
2. P3+ deferred tail (no `/relay/*` rate limiting, container-as-root, dashboard `innerHTML` XSS surface, prune 126 version files, `/relay/health` stale `0.2.11`, etc.) + speaker-tool/empty-section backlog → `ROADMAP.md` / `docs/issues-backlog.md`.
|
||||
- **Risks/notes:** webhook dedup keeps the pre-existing check-then-mark race for *truly simultaneous* duplicate deliveries (vanishingly rare on a private box; would need locking). SSRF guard leaves a DNS-rebinding TOCTOU open (acceptable for a private box). Full prior eval → `EVALUATION.md`.
|
||||
|
||||
+10
@@ -14,6 +14,16 @@ Longer-term backlog for the relay. Near-term in-flight work + known box/local st
|
||||
|
||||
- **Empty analysis section at a window boundary** (observed v0.2.77 smoke test). Likely the LLM returning an empty `{title:"",summary:""}` section the stitcher accepts, or a window-merge boundary hole. Low priority. Full triage path in `docs/issues-backlog.md`.
|
||||
|
||||
## Post-eval P3+ backlog (full eval 2026-06-13 — deferred, low risk for the private box)
|
||||
|
||||
From `EVALUATION.md`. P1 + three P2 items already fixed (see git log `8ad7c54`…`693d724`); these are the deferred tail.
|
||||
|
||||
- **Security hardening:** no `/relay/*` rate limiting; container likely runs as root (entrypoint `chown`s uid 1001 but no `USER` directive); dashboard `innerHTML` stored-XSS surface; `lan-fetch` TLS verify off (admin-set URL only); debug/error fields leaked to clients.
|
||||
- **Packaging/ops:** prune the 126 `startos/versions/*.ts` files; pin `yt-dlp` in the Dockerfile; the Dockerfile per-subdir `COPY` footgun; manifest polish (SPDX license, `docsUrls`, real repo URLs, icon format); no `README.md` (blocks public-registry submission only — moot for this private box).
|
||||
- **`/relay/health` reports stale `0.2.11`** — `server/package.json` version never bumped past 0.2.11; bump to track the StartOS version.
|
||||
- **Doc fixes (bulk):** the `test/` layout line; `server/index.js:3-6` "two endpoints" header comment is stale; `POST /admin/logout` undocumented.
|
||||
- **Untested blind spot:** the live upload → merge → recluster → repolish pipeline (admin-gated + needs Spark Control) has only unit coverage; re-run `npm audit`/`osv-scanner` with network to catch transitive CVEs the offline audit missed.
|
||||
|
||||
## Adjacent (lives in `../recap`)
|
||||
|
||||
The app surfaces relay features but owns its own roadmap. Relay-side items the app is waiting on, or that change app behavior, belong in `../recap/ROADMAP.md` under its "Adjacent" section — keep them cross-referenced, not duplicated.
|
||||
|
||||
+179
-1
@@ -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 '<div class="tabs">' +
|
||||
t("overview", "Overview") +
|
||||
t("jobs", "Jobs") +
|
||||
t("users", "Users") +
|
||||
t("meetings", "Internal Meetings") +
|
||||
t("settings", "Settings") +
|
||||
'</div>';
|
||||
@@ -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 @@
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ── 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 ? '<span class="dim">Unlimited</span>' : 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 '<div class="empty">' + (q ? "No users match “" + esc(q) + "”." : "No users yet.") + '</div>';
|
||||
}
|
||||
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 '<th class="' + (c.num ? "num " : "") + '" style="cursor:pointer;" onclick="sortUsers(\'' + c.key + '\')">'
|
||||
+ esc(c.label) + '<span style="opacity:0.7;font-size:9px;">' + ind + '</span></th>';
|
||||
}).join("") + '<th>Grant</th>';
|
||||
|
||||
const tbody = filtered.map((r) => {
|
||||
const typeBadge = '<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;'
|
||||
+ 'background:rgba(165,180,252,0.12);border:1px solid var(--line-2);color:var(--fg-dim);">'
|
||||
+ esc(r.type) + '</span>';
|
||||
const grantCell = '<div style="display:flex;gap:5px;align-items:center;">'
|
||||
+ '<input type="number" min="1" step="1" placeholder="N" class="grant-amt" '
|
||||
+ 'style="width:62px;padding:4px 6px;font-size:12px;background:var(--bg);border:1px solid var(--line-2);border-radius:5px;color:var(--fg);" />'
|
||||
+ '<button class="grant-btn" data-key="' + esc(r.credit_key) + '" onclick="grantCredits(this)" '
|
||||
+ 'style="padding:4px 10px;font-size:11px;background:transparent;border:1px solid var(--line-2);border-radius:5px;color:var(--accent);cursor:pointer;">Grant</button>'
|
||||
+ '</div>';
|
||||
return '<tr>'
|
||||
+ '<td><code title="' + esc(r.credit_key) + '">' + esc(shortId(r.credit_key)) + '</code></td>'
|
||||
+ '<td>' + typeBadge + '</td>'
|
||||
+ '<td>' + tierPill(r.tier_snapshot) + '</td>'
|
||||
+ '<td class="num">' + fmtCredits(r.remaining) + '</td>'
|
||||
+ '<td class="num">' + fmtInt(r.purchased || 0) + '</td>'
|
||||
+ '<td class="num">' + fmtCredits(r.total) + '</td>'
|
||||
+ '<td class="num" title="' + (r.capped === "monthly" ? "monthly allowance" : "lifetime allowance") + '">' + fmtInt(usedCount(r)) + '</td>'
|
||||
+ '<td><span class="dim">' + esc(fmtTs(r.last_active_at)) + '</span></td>'
|
||||
+ '<td>' + grantCell + '</td>'
|
||||
+ '</tr>';
|
||||
}).join("");
|
||||
|
||||
return '<table><thead><tr>' + thead + '</tr></thead><tbody>' + tbody + '</tbody></table>';
|
||||
}
|
||||
|
||||
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 = '<div class="loading">' + (state.creditsLoading ? "Loading users…" : "No data.") + '</div>';
|
||||
} else {
|
||||
const counts = data.reduce((acc, r) => { acc[r.type] = (acc[r.type] || 0) + 1; return acc; }, {});
|
||||
const summary = '<div style="font-size:11px;color:var(--fg-dim);margin-bottom:12px;">'
|
||||
+ 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).'
|
||||
+ '</div>';
|
||||
const search = '<input type="text" placeholder="Filter by key…" value="' + esc(state.creditsQuery || "") + '" '
|
||||
+ 'oninput="filterUsers(this.value)" '
|
||||
+ 'style="margin-bottom:12px;width:260px;padding:6px 10px;font-size:12px;background:var(--bg);border:1px solid var(--line-2);border-radius:5px;color:var(--fg);" />';
|
||||
body = summary + search + '<div id="users-table-wrap">' + usersTableHtml() + '</div>';
|
||||
}
|
||||
root.innerHTML =
|
||||
'<h1>Recap Relay — Operator Dashboard</h1>' +
|
||||
tabsHtml() +
|
||||
'<div style="max-width:1100px; padding:12px 0;">' +
|
||||
body +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function table(headers, rows) {
|
||||
if (rows.length === 0) return '<div class="empty">No data in this range.</div>';
|
||||
const thead = headers.map(h => '<th>' + esc(h) + '</th>').join("");
|
||||
|
||||
+23
-7
@@ -741,21 +741,26 @@ export async function addPurchasedCredits({
|
||||
// stored on the user's credit row (keyed `user:<id>`). Set by the
|
||||
// operator (today) and the self-serve purchase flow (later slice).
|
||||
|
||||
// Operator-set a cloud user's tier. Resets the monthly counters and
|
||||
// anchors the renewal to now (so the monthly cycle starts on the grant
|
||||
// date), mirroring applyTierPromotion. `expiresAt` is stored for
|
||||
// reporting / future self-serve billing but NOT auto-enforced in this
|
||||
// slice — to revoke, set tier back to "core".
|
||||
export async function setUserTier({ userId, tier, expiresAt = null }) {
|
||||
// Operator-set a cloud user's tier. With `resetCycle` (the default) it
|
||||
// starts a fresh monthly cycle anchored to now — so an operator comp
|
||||
// grant, or a first/lapsed self-serve purchase, begins its allowance on
|
||||
// the grant date (mirroring applyTierPromotion). A renewal of an
|
||||
// in-force subscription passes `resetCycle: false` so it extends the
|
||||
// expiry WITHOUT zeroing monthly_consumed — see extendUserTier.
|
||||
// `expiresAt` is stored for reporting / self-serve billing but NOT
|
||||
// auto-enforced here — to revoke, set tier back to "core".
|
||||
export async function setUserTier({ userId, tier, expiresAt = null, resetCycle = true }) {
|
||||
if (!userId) throw new Error("setUserTier: userId required");
|
||||
const t = tier === "pro" || tier === "max" ? tier : "core";
|
||||
const row = await getOrCreateRow({ creditKey: `user:${userId}` });
|
||||
const now = new Date();
|
||||
row.tier_snapshot = t;
|
||||
if (resetCycle) {
|
||||
row.monthly_consumed = 0;
|
||||
row.monthly_gemini_consumed = 0;
|
||||
row.last_renewal_at = now.toISOString();
|
||||
row.anniversary_day = now.getUTCDate();
|
||||
}
|
||||
row.subscription_expires_at = expiresAt || null;
|
||||
row.last_active_at = now.toISOString();
|
||||
await persist();
|
||||
@@ -767,6 +772,13 @@ export async function setUserTier({ userId, tier, expiresAt = null }) {
|
||||
// (still-active) expiry — so paying early ADDS time rather than resetting
|
||||
// it. `periodDays` defaults to 30. Both payment rails (BTCPay + Zaprite)
|
||||
// land here on a settled payment. Returns the updated row.
|
||||
//
|
||||
// Renewing an IN-FORCE subscription must NOT reset the monthly credit
|
||||
// counter — otherwise a user who paid early (or a webhook that double-
|
||||
// fired across a restart) would get their whole monthly allotment back
|
||||
// for free. The monthly cycle rolls on its own anniversary via
|
||||
// ensureRenewalRollover, independent of renewals. Only a brand-new or
|
||||
// lapsed subscription starts a fresh cycle.
|
||||
export async function extendUserTier({ userId, tier, periodDays = 30 }) {
|
||||
if (!userId) throw new Error("extendUserTier: userId required");
|
||||
const t = tier === "pro" || tier === "max" ? tier : "core";
|
||||
@@ -775,11 +787,15 @@ export async function extendUserTier({ userId, tier, periodDays = 30 }) {
|
||||
const curExp = row.subscription_expires_at
|
||||
? new Date(row.subscription_expires_at).getTime()
|
||||
: 0;
|
||||
const hasActiveSub =
|
||||
Number.isFinite(curExp) &&
|
||||
curExp > now &&
|
||||
(row.tier_snapshot === "pro" || row.tier_snapshot === "max");
|
||||
const base = Math.max(now, Number.isFinite(curExp) ? curExp : 0);
|
||||
const expiresAt = new Date(
|
||||
base + periodDays * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
return setUserTier({ userId, tier: t, expiresAt });
|
||||
return setUserTier({ userId, tier: t, expiresAt, resetCycle: !hasActiveSub });
|
||||
}
|
||||
|
||||
// Read a cloud user's credit row (creates a blank Core row if none yet).
|
||||
|
||||
+17
-2
@@ -23,10 +23,25 @@
|
||||
// A route bills against `creditKey`; for tier it uses the stored row tier
|
||||
// (cloud) or `license.tier` (license) — see identityTier().
|
||||
|
||||
import { timingSafeEqual } from "crypto";
|
||||
import { getConfigSnapshot } from "./config.js";
|
||||
import { resolveLicense } from "./keysat-client.js";
|
||||
import { getCreditKey } from "./credits.js";
|
||||
|
||||
// Constant-time string compare for the shared operator key, so a token
|
||||
// guess can't be tuned byte-by-byte off response timing. Mirrors the
|
||||
// helper in admin-auth.js. A length mismatch returns false early —
|
||||
// length isn't the secret, and timingSafeEqual requires equal length.
|
||||
function constantTimeEqual(a, b) {
|
||||
if (typeof a !== "string" || typeof b !== "string") return false;
|
||||
if (a.length !== b.length) return false;
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// user-ids come from Recaps (base64url / hex account ids). Constrain the
|
||||
// charset so a header can't smuggle a path-ish or oversized key into the
|
||||
// ledger.
|
||||
@@ -40,7 +55,7 @@ export async function resolveIdentity(req) {
|
||||
const cfg = await getConfigSnapshot();
|
||||
const expected = (cfg.relay_cloud_operator_key || "").trim();
|
||||
const presented = (req.header("X-Recap-Operator-Key") || "").trim();
|
||||
if (!expected || !presented || presented !== expected) {
|
||||
if (!expected || !presented || !constantTimeEqual(presented, expected)) {
|
||||
const e = new Error(
|
||||
"X-Recap-User-Id requires a valid X-Recap-Operator-Key"
|
||||
);
|
||||
@@ -81,7 +96,7 @@ export async function verifyOperatorKey(req) {
|
||||
const cfg = await getConfigSnapshot();
|
||||
const expected = (cfg.relay_cloud_operator_key || "").trim();
|
||||
const presented = (req.header("X-Recap-Operator-Key") || "").trim();
|
||||
return !!expected && !!presented && presented === expected;
|
||||
return !!expected && !!presented && constantTimeEqual(presented, expected);
|
||||
}
|
||||
|
||||
// The tier to bill/quota at for a resolved identity.
|
||||
|
||||
+23
-1
@@ -17,6 +17,7 @@ import { initCredits } from "./credits.js";
|
||||
import { initAuditLog } from "./audit-log.js";
|
||||
import { initJobCredits } from "./job-credits.js";
|
||||
import { initOutputStore } from "./output-store.js";
|
||||
import { initWebhookDedup } from "./webhook-dedup.js";
|
||||
import {
|
||||
setupAdminAuthMiddleware,
|
||||
setupAdminAuthRoutes,
|
||||
@@ -49,9 +50,14 @@ await initCredits({ dataDir: DATA_DIR });
|
||||
await initJobCredits({ dataDir: DATA_DIR });
|
||||
await initAuditLog({ dataDir: DATA_DIR });
|
||||
await initOutputStore({ dataDir: DATA_DIR });
|
||||
await initWebhookDedup({ dataDir: DATA_DIR });
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
// CORS only on /relay/* — those are the cross-origin clients (the Recaps
|
||||
// app + cloud server). /admin/* and the dashboard are served same-origin
|
||||
// to the operator's browser, so they don't need (and shouldn't get) the
|
||||
// permissive Access-Control-Allow-Origin a global cors() would apply.
|
||||
app.use("/relay", cors());
|
||||
app.use(cookieParser());
|
||||
|
||||
// Admin auth must run BEFORE the admin routes register so the cookie
|
||||
@@ -105,6 +111,22 @@ app.get("/", (_req, res) => {
|
||||
res.redirect("/dashboard.html");
|
||||
});
|
||||
|
||||
// Error handler (must be last). Without it, body-parser parse failures
|
||||
// and any other propagated error fall through to Express's default
|
||||
// handler, which renders an HTML page including the local-filesystem
|
||||
// stack trace — an info leak. Return clean JSON instead.
|
||||
app.use((err, _req, res, next) => {
|
||||
if (res.headersSent) return next(err); // mid-stream (SSE) — can't rewrite
|
||||
if (err?.type === "entity.parse.failed") {
|
||||
return res.status(400).json({ error: "invalid JSON body" });
|
||||
}
|
||||
if (err?.type === "entity.too.large") {
|
||||
return res.status(413).json({ error: "request body too large" });
|
||||
}
|
||||
console.error("[relay] unhandled error:", err?.message || err);
|
||||
return res.status(500).json({ error: "internal error" });
|
||||
});
|
||||
|
||||
const HOSTNAME = process.env.HOSTNAME || "0.0.0.0";
|
||||
app.listen(PORT, HOSTNAME, () => {
|
||||
console.log(`[relay] listening on http://${HOSTNAME}:${PORT}`);
|
||||
|
||||
Generated
+1442
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -12,7 +12,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.21.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.0.1",
|
||||
"undici": "^6.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
+72
-2
@@ -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);
|
||||
|
||||
+17
-19
@@ -49,18 +49,16 @@ import {
|
||||
BtcPayError,
|
||||
} from "../btcpay-client.js";
|
||||
import { recordCall } from "../audit-log.js";
|
||||
import { isWebhookProcessed, markWebhookProcessed } from "../webhook-dedup.js";
|
||||
import { envelope, errorEnvelope } from "./envelope.js";
|
||||
|
||||
// In-memory dedup of processed BTCPay invoice ids. BTCPay retries
|
||||
// webhook deliveries on non-2xx responses or network errors, so the
|
||||
// same InvoiceSettled event can land more than once. We don't want
|
||||
// to grant credits twice for one paid invoice.
|
||||
//
|
||||
// Cleared on relay restart, which means an unlucky webhook duplicate
|
||||
// straddling a restart would double-credit. Acceptable tradeoff for
|
||||
// v1 (operator can manually adjust the ledger if it happens), but
|
||||
// worth swapping for a persistent set once the relay sees real load.
|
||||
const processedInvoices = new Set();
|
||||
// Dedup of processed BTCPay invoice ids. BTCPay retries webhook
|
||||
// deliveries on non-2xx responses or network errors, so the same
|
||||
// InvoiceSettled event can land more than once — we don't want to grant
|
||||
// credits twice for one paid invoice. Backed by the persistent
|
||||
// webhook-dedup store (../webhook-dedup.js) so a duplicate straddling a
|
||||
// relay restart can't double-credit. Keys are namespaced
|
||||
// `<storeId>|<invoiceId>` to share the store with the Zaprite rail.
|
||||
|
||||
export function creditsRouter() {
|
||||
const router = express.Router();
|
||||
@@ -452,7 +450,7 @@ export function creditsRouter() {
|
||||
}
|
||||
|
||||
const dedupKey = `${cfg.relay_btcpay_store_id}|${invoiceId}`;
|
||||
if (processedInvoices.has(dedupKey)) {
|
||||
if (isWebhookProcessed(dedupKey)) {
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "already_processed", invoiceId });
|
||||
@@ -487,7 +485,7 @@ export function creditsRouter() {
|
||||
const subTier = meta.tier;
|
||||
const periodDays = Number(meta.period_days) || 30;
|
||||
if (!subUserId || (subTier !== "pro" && subTier !== "max")) {
|
||||
processedInvoices.add(dedupKey);
|
||||
await markWebhookProcessed(dedupKey);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "bad_tier_metadata", invoiceId });
|
||||
@@ -498,7 +496,7 @@ export function creditsRouter() {
|
||||
tier: subTier,
|
||||
periodDays,
|
||||
});
|
||||
processedInvoices.add(dedupKey);
|
||||
await markWebhookProcessed(dedupKey);
|
||||
console.log(
|
||||
`[btcpay/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (invoice ${invoiceId}) → expires ${row.subscription_expires_at}`,
|
||||
);
|
||||
@@ -529,7 +527,7 @@ export function creditsRouter() {
|
||||
// Not ours — could be an invoice from another product on the
|
||||
// same store. Mark processed so we don't keep retrying, and
|
||||
// 200 so BTCPay stops calling.
|
||||
processedInvoices.add(dedupKey);
|
||||
await markWebhookProcessed(dedupKey);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "no_recap_metadata" });
|
||||
@@ -541,7 +539,7 @@ export function creditsRouter() {
|
||||
creditKey,
|
||||
amount: credits,
|
||||
});
|
||||
processedInvoices.add(dedupKey);
|
||||
await markWebhookProcessed(dedupKey);
|
||||
await recordCall({
|
||||
install_id: installId,
|
||||
license_fingerprint: buyerFp,
|
||||
@@ -614,8 +612,8 @@ function rewriteCheckoutUrl(url, browserBase) {
|
||||
// Recovery: when the BTCPay webhook URL was broken and paid
|
||||
// invoices never got credited, this scans BTCPay's recent settled
|
||||
// invoices and grants credits for ones the relay hasn't processed.
|
||||
// Idempotent via the same processedInvoices dedup the webhook uses,
|
||||
// so re-running is safe.
|
||||
// Idempotent via the same persistent webhook-dedup store the webhook
|
||||
// uses, so re-running is safe.
|
||||
//
|
||||
// Exported so the admin route in btcpay-setup.js can call it. Not
|
||||
// exposed via /relay/* because it's operator-initiated, not buyer.
|
||||
@@ -660,7 +658,7 @@ export async function rescanSettledInvoices() {
|
||||
const details = [];
|
||||
for (const invoice of invoices || []) {
|
||||
const dedupKey = `${cfg.relay_btcpay_store_id}|${invoice.id}`;
|
||||
if (processedInvoices.has(dedupKey)) {
|
||||
if (isWebhookProcessed(dedupKey)) {
|
||||
alreadyProcessed++;
|
||||
continue;
|
||||
}
|
||||
@@ -684,7 +682,7 @@ export async function rescanSettledInvoices() {
|
||||
creditKey,
|
||||
amount: credits,
|
||||
});
|
||||
processedInvoices.add(dedupKey);
|
||||
await markWebhookProcessed(dedupKey);
|
||||
await recordCall({
|
||||
install_id: installId,
|
||||
license_fingerprint: buyerFp,
|
||||
|
||||
@@ -77,19 +77,31 @@ async function ensureMeetingsDir(dataDir) {
|
||||
await fs.mkdir(meetingsDir(dataDir), { recursive: true }).catch(() => {});
|
||||
}
|
||||
|
||||
// Build the on-disk path for a meeting record, sanitizing the id so a
|
||||
// caller-supplied :id can't traverse out of internal-meetings/. Real
|
||||
// ids are UUIDs; anything outside [A-Za-z0-9_-] is stripped (mirrors
|
||||
// output-store.js's pathFor). Throws when the id sanitizes to empty —
|
||||
// load/delete catch it (→ 404 / no-op); save only ever gets a freshly
|
||||
// minted id.
|
||||
export function meetingPath(dataDir, id) {
|
||||
const safe = String(id || "").replace(/[^A-Za-z0-9_-]/g, "");
|
||||
if (!safe) throw new Error("invalid meeting id");
|
||||
return path.join(meetingsDir(dataDir), `${safe}.json`);
|
||||
}
|
||||
|
||||
// ─── Storage layer ──────────────────────────────────────────────────
|
||||
|
||||
async function saveMeeting(dataDir, id, record) {
|
||||
await ensureMeetingsDir(dataDir);
|
||||
const filePath = path.join(meetingsDir(dataDir), `${id}.json`);
|
||||
const filePath = meetingPath(dataDir, id);
|
||||
await fs.writeFile(filePath, JSON.stringify(record, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMeeting(dataDir, id) {
|
||||
const filePath = path.join(meetingsDir(dataDir), `${id}.json`);
|
||||
try {
|
||||
const filePath = meetingPath(dataDir, id);
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
const rec = JSON.parse(raw);
|
||||
// Retroactive chunk-contiguity backfill must run BEFORE the
|
||||
@@ -239,8 +251,8 @@ async function listMeetings(dataDir) {
|
||||
}
|
||||
|
||||
async function deleteMeeting(dataDir, id) {
|
||||
const filePath = path.join(meetingsDir(dataDir), `${id}.json`);
|
||||
try {
|
||||
const filePath = meetingPath(dataDir, id);
|
||||
await fs.unlink(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
|
||||
@@ -50,6 +50,7 @@ import { calcGeminiCost } from "../pricing.js";
|
||||
import { getAudioDurationSeconds } from "../audio-meta.js";
|
||||
import { resolveHardwareConfig } from "../hardware-config.js";
|
||||
import { reportHealthEvent } from "../spark-control-events.js";
|
||||
import { safeFetch } from "../safe-url.js";
|
||||
import {
|
||||
createJob,
|
||||
markRunning,
|
||||
@@ -97,8 +98,12 @@ function guessMimeFromExt(filePath) {
|
||||
// would exceed MAX_DOWNLOAD_BYTES. Returns { filePath, bytes,
|
||||
// mimeType }.
|
||||
export async function downloadDirect(url, tmpDir) {
|
||||
const res = await fetch(url, {
|
||||
redirect: "follow",
|
||||
// safeFetch is the SSRF choke point: it rejects non-http(s) schemes
|
||||
// and hosts resolving to private/reserved ranges, and re-validates
|
||||
// every redirect hop. downloadDirect is the single download path for
|
||||
// transcribe-url / summarize-url / admin-test-run, so guarding it
|
||||
// here covers all three.
|
||||
const res = await safeFetch(url, {
|
||||
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -15,16 +15,14 @@ import express from "express";
|
||||
import { extendUserTier } from "../credits.js";
|
||||
import { getZapriteConfig } from "../config.js";
|
||||
import { getOrder, isOrderPaid, orderIdFromWebhook } from "../zaprite-client.js";
|
||||
import { isWebhookProcessed, markWebhookProcessed } from "../webhook-dedup.js";
|
||||
|
||||
// In-memory dedup of fully-processed orders (mirrors the BTCPay handler's
|
||||
// processedInvoices). Zaprite retries on non-200, and may fire multiple
|
||||
// events per order, so we guard the grant. Cleared on restart — a
|
||||
// re-delivered webhook after restart re-fetches + re-grants, but
|
||||
// extendUserTier is keyed per order via this set within a process; a
|
||||
// duplicate grant across a restart is the same harmless "extend by one
|
||||
// period" the operator-comp path already tolerates. (Acceptable: card
|
||||
// double-fires across a restart are vanishingly rare.)
|
||||
const processedZaprite = new Set();
|
||||
// Dedup of fully-processed orders (mirrors the BTCPay handler). Zaprite
|
||||
// retries on non-200, and may fire multiple events per order, so we
|
||||
// guard the grant. Backed by the persistent webhook-dedup store
|
||||
// (../webhook-dedup.js) so a re-delivered webhook straddling a relay
|
||||
// restart can't double-extend a subscription. Keys are namespaced
|
||||
// `zaprite:<orderId>` to share the store with the BTCPay rail.
|
||||
|
||||
export function zapriteWebhookRouter() {
|
||||
const router = express.Router();
|
||||
@@ -49,7 +47,7 @@ export function zapriteWebhookRouter() {
|
||||
return res.status(200).json({ ok: true, ignored: "no_order_id" });
|
||||
}
|
||||
const dedupKey = `zaprite:${orderId}`;
|
||||
if (processedZaprite.has(dedupKey)) {
|
||||
if (isWebhookProcessed(dedupKey)) {
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "already_processed", orderId });
|
||||
@@ -91,7 +89,7 @@ export function zapriteWebhookRouter() {
|
||||
|
||||
const meta = order.metadata || {};
|
||||
if (meta.product !== "recap_tier_subscription") {
|
||||
processedZaprite.add(dedupKey);
|
||||
await markWebhookProcessed(dedupKey);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "not_a_tier_order", orderId });
|
||||
@@ -100,7 +98,7 @@ export function zapriteWebhookRouter() {
|
||||
const subTier = meta.tier;
|
||||
const periodDays = Number(meta.period_days) || 30;
|
||||
if (!subUserId || (subTier !== "pro" && subTier !== "max")) {
|
||||
processedZaprite.add(dedupKey);
|
||||
await markWebhookProcessed(dedupKey);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "bad_tier_metadata", orderId });
|
||||
@@ -112,7 +110,7 @@ export function zapriteWebhookRouter() {
|
||||
tier: subTier,
|
||||
periodDays,
|
||||
});
|
||||
processedZaprite.add(dedupKey);
|
||||
await markWebhookProcessed(dedupKey);
|
||||
console.log(
|
||||
`[zaprite/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (order ${orderId}) → expires ${row.subscription_expires_at}`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// SSRF guard for user-supplied media URLs.
|
||||
//
|
||||
// /relay/transcribe-url and /relay/summarize-url download whatever
|
||||
// `media_url` the caller passes, and the route is reachable by anyone
|
||||
// presenting a self-chosen X-Recap-Install-Id, so an unguarded fetch
|
||||
// lets a caller probe the operator's LAN (Spark Control, BTCPay, other
|
||||
// StartOS services) or cloud metadata at 169.254.169.254. This module
|
||||
// rejects non-http(s) schemes and any hostname that resolves to a
|
||||
// private / loopback / link-local / reserved address, and follows
|
||||
// redirects MANUALLY so every hop is re-validated — a public URL can
|
||||
// 302 to an internal one after the first check passes.
|
||||
//
|
||||
// LAN calls to the operator's OWN hardware go through lan-fetch.js
|
||||
// instead: those URLs are config-set, not caller-set, and intentionally
|
||||
// reach private hosts.
|
||||
|
||||
import dns from "node:dns/promises";
|
||||
import net from "node:net";
|
||||
|
||||
export class BlockedUrlError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "BlockedUrlError";
|
||||
this.code = "BLOCKED_URL";
|
||||
}
|
||||
}
|
||||
|
||||
// Parse an IPv4 dotted-quad into its 32-bit integer, or null if it
|
||||
// isn't a well-formed IPv4 literal.
|
||||
function ipv4ToInt(ip) {
|
||||
const parts = ip.split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
let n = 0;
|
||||
for (const p of parts) {
|
||||
if (!/^\d{1,3}$/.test(p)) return null;
|
||||
const v = Number(p);
|
||||
if (v > 255) return null;
|
||||
n = n * 256 + v;
|
||||
}
|
||||
return n >>> 0;
|
||||
}
|
||||
|
||||
function inV4Range(n, base, bits) {
|
||||
const mask = bits === 0 ? 0 : (~((1 << (32 - bits)) - 1)) >>> 0;
|
||||
return (n & mask) === (base & mask);
|
||||
}
|
||||
|
||||
// IPv4 ranges that must never be fetched from a user-supplied URL.
|
||||
const BLOCKED_V4 = [
|
||||
["0.0.0.0", 8], // "this host"
|
||||
["10.0.0.0", 8], // private
|
||||
["100.64.0.0", 10], // CGNAT
|
||||
["127.0.0.0", 8], // loopback
|
||||
["169.254.0.0", 16], // link-local (incl. 169.254.169.254 cloud metadata)
|
||||
["172.16.0.0", 12], // private
|
||||
["192.0.0.0", 24], // IETF protocol assignments
|
||||
["192.0.2.0", 24], // TEST-NET-1
|
||||
["192.168.0.0", 16], // private
|
||||
["198.18.0.0", 15], // benchmarking
|
||||
["198.51.100.0", 24], // TEST-NET-2
|
||||
["203.0.113.0", 24], // TEST-NET-3
|
||||
["224.0.0.0", 4], // multicast
|
||||
["240.0.0.0", 4], // reserved (incl. 255.255.255.255 broadcast)
|
||||
];
|
||||
|
||||
function isBlockedV4(ip) {
|
||||
const n = ipv4ToInt(ip);
|
||||
if (n === null) return false;
|
||||
for (const [base, bits] of BLOCKED_V4) {
|
||||
if (inV4Range(n, ipv4ToInt(base), bits)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Classify the reserved/private IPv6 ranges we block. Handles
|
||||
// IPv4-mapped (::ffff:a.b.c.d) by delegating to the v4 check.
|
||||
function isBlockedV6(ip) {
|
||||
let addr = ip.toLowerCase();
|
||||
const pct = addr.indexOf("%"); // strip zone id (fe80::1%eth0)
|
||||
if (pct !== -1) addr = addr.slice(0, pct);
|
||||
// IPv4-mapped / -embedded (::ffff:192.168.0.1, ::192.168.0.1).
|
||||
const tail = addr.slice(addr.lastIndexOf(":") + 1);
|
||||
if (tail.includes(".") && isBlockedV4(tail)) return true;
|
||||
if (addr === "::1") return true; // loopback
|
||||
if (addr === "::") return true; // unspecified
|
||||
// fe80::/10 link-local spans fe80–febf.
|
||||
if (/^fe[89ab]/.test(addr)) return true;
|
||||
if (/^f[cd]/.test(addr)) return true; // fc00::/7 unique-local
|
||||
if (addr.startsWith("ff")) return true; // multicast
|
||||
return false;
|
||||
}
|
||||
|
||||
// True if `ip` (an IP literal) is in a range we refuse to fetch from.
|
||||
// Returns false for non-IP strings — the caller resolves DNS first.
|
||||
export function isBlockedAddress(ip) {
|
||||
const kind = net.isIP(ip);
|
||||
if (kind === 4) return isBlockedV4(ip);
|
||||
if (kind === 6) return isBlockedV6(ip);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate that `urlStr` is an http(s) URL whose host does NOT resolve
|
||||
// to a private/reserved address. Throws BlockedUrlError otherwise;
|
||||
// returns the parsed URL on success.
|
||||
export async function assertPublicHttpUrl(urlStr) {
|
||||
let u;
|
||||
try {
|
||||
u = new URL(urlStr);
|
||||
} catch {
|
||||
throw new BlockedUrlError("media_url is not a valid URL");
|
||||
}
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
||||
throw new BlockedUrlError(`media_url scheme "${u.protocol}" is not allowed`);
|
||||
}
|
||||
const host = u.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
||||
let addresses;
|
||||
if (net.isIP(host)) {
|
||||
addresses = [host];
|
||||
} else {
|
||||
let looked;
|
||||
try {
|
||||
looked = await dns.lookup(host, { all: true });
|
||||
} catch {
|
||||
throw new BlockedUrlError(`media_url host "${host}" did not resolve`);
|
||||
}
|
||||
addresses = looked.map((a) => a.address);
|
||||
}
|
||||
if (!addresses.length) {
|
||||
throw new BlockedUrlError(`media_url host "${host}" did not resolve`);
|
||||
}
|
||||
for (const addr of addresses) {
|
||||
if (isBlockedAddress(addr)) {
|
||||
throw new BlockedUrlError(
|
||||
`media_url host "${host}" resolves to a blocked address`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
// fetch() wrapper that re-validates the URL on every redirect hop. Node
|
||||
// fetch's redirect:"follow" would jump to an internal host AFTER the
|
||||
// initial check passed, so we follow manually with redirect:"manual"
|
||||
// and re-run assertPublicHttpUrl on each Location.
|
||||
export async function safeFetch(urlStr, { signal, headers, maxRedirects = 5 } = {}) {
|
||||
let current = urlStr;
|
||||
for (let hop = 0; hop <= maxRedirects; hop++) {
|
||||
await assertPublicHttpUrl(current);
|
||||
const res = await fetch(current, { redirect: "manual", signal, headers });
|
||||
const location = res.headers.get("location");
|
||||
if (res.status >= 300 && res.status < 400 && location) {
|
||||
current = new URL(location, current).toString(); // resolve relative redirects
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
throw new BlockedUrlError("media_url exceeded the redirect limit");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// Path-traversal guard for meeting record ids (internal-meetings.js
|
||||
// meetingPath). A caller-supplied :id must never escape the
|
||||
// internal-meetings/ directory.
|
||||
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { meetingPath } from "../routes/internal-meetings.js";
|
||||
|
||||
const DATA = "/data";
|
||||
const DIR = path.join(DATA, "internal-meetings");
|
||||
|
||||
describe("meetingPath", () => {
|
||||
test("a normal UUID id maps into internal-meetings/", () => {
|
||||
const id = "2f1c9b3a-0e4d-4a77-9d2a-abc123def456";
|
||||
assert.equal(meetingPath(DATA, id), path.join(DIR, `${id}.json`));
|
||||
});
|
||||
|
||||
test("traversal-shaped ids are sanitized and stay inside the dir", () => {
|
||||
for (const id of ["../../etc/passwd", "../../../root/.ssh/id", "..%2f..%2fx", "a/b/c", "....//x"]) {
|
||||
const p = meetingPath(DATA, id);
|
||||
const rel = path.relative(DIR, p);
|
||||
assert.ok(!rel.startsWith(".."), `${id} escaped to ${p}`);
|
||||
assert.ok(!p.includes(".."), `${id} left ".." in ${p}`);
|
||||
assert.ok(p.endsWith(".json"));
|
||||
}
|
||||
});
|
||||
|
||||
test("an id that sanitizes to empty throws (load/delete catch → 404 / no-op)", () => {
|
||||
for (const id of ["", null, undefined, "/", "../", "...", "!!!"]) {
|
||||
assert.throws(() => meetingPath(DATA, id), /invalid meeting id/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// Core billing primitives: commitCredit (debit), refundCredit (inverse),
|
||||
// and applyTierPromotion (the Core→paid upgrade bookkeeping). Default
|
||||
// quotas (no config file → getTierQuotas falls back): Core lifetime=10,
|
||||
// geminiCapLifetime=5; Pro monthly=50, geminiCapMonthly=25.
|
||||
|
||||
import { test, describe, before } 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 {
|
||||
initCredits,
|
||||
getOrCreateRow,
|
||||
getUserCreditRow,
|
||||
setUserTier,
|
||||
commitCredit,
|
||||
refundCredit,
|
||||
applyTierPromotion,
|
||||
} from "../credits.js";
|
||||
|
||||
before(async () => {
|
||||
await initCredits({ dataDir: mkdtempSync(path.join(tmpdir(), "relay-money-")) });
|
||||
});
|
||||
|
||||
describe("commitCredit", () => {
|
||||
test("Core debits the lifetime bucket; Gemini tracked separately", async () => {
|
||||
const creditKey = "inst:core-commit";
|
||||
await commitCredit({ creditKey, tier: "core", backend: "gemini" });
|
||||
let row = await getOrCreateRow({ creditKey });
|
||||
assert.equal(row.lifetime_consumed, 1);
|
||||
assert.equal(row.lifetime_gemini_consumed, 1);
|
||||
|
||||
// A hardware call bumps lifetime but NOT the Gemini sub-counter.
|
||||
await commitCredit({ creditKey, tier: "core", backend: "hardware" });
|
||||
row = await getOrCreateRow({ creditKey });
|
||||
assert.equal(row.lifetime_consumed, 2);
|
||||
assert.equal(row.lifetime_gemini_consumed, 1);
|
||||
});
|
||||
|
||||
test("paid tier debits the monthly bucket", async () => {
|
||||
await setUserTier({ userId: "p-commit", tier: "pro" });
|
||||
await commitCredit({ creditKey: "user:p-commit", tier: "pro", backend: "gemini" });
|
||||
const row = await getUserCreditRow("p-commit");
|
||||
assert.equal(row.monthly_consumed, 1);
|
||||
assert.equal(row.monthly_gemini_consumed, 1);
|
||||
});
|
||||
|
||||
test("spend order: once the tier bucket is exhausted, debit purchased", async () => {
|
||||
const creditKey = "inst:core-exhausted";
|
||||
const row = await getOrCreateRow({ creditKey });
|
||||
row.lifetime_consumed = 10; // at the Core lifetime cap
|
||||
row.purchased_balance = 3;
|
||||
await commitCredit({ creditKey, tier: "core", backend: "hardware" });
|
||||
const after = await getOrCreateRow({ creditKey });
|
||||
assert.equal(after.lifetime_consumed, 10, "tier counter must not exceed the cap");
|
||||
assert.equal(after.purchased_balance, 2, "overflow comes out of purchased");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refundCredit", () => {
|
||||
test("mirrors a Core commit (tier bucket first, Gemini sub-counter too)", async () => {
|
||||
const creditKey = "inst:core-refund";
|
||||
await commitCredit({ creditKey, tier: "core", backend: "gemini" });
|
||||
await refundCredit({ creditKey, tier: "core", backend: "gemini" });
|
||||
const row = await getOrCreateRow({ creditKey });
|
||||
assert.equal(row.lifetime_consumed, 0);
|
||||
assert.equal(row.lifetime_gemini_consumed, 0);
|
||||
});
|
||||
|
||||
test("refund with an empty tier bucket credits the purchased bucket", async () => {
|
||||
const creditKey = "inst:refund-to-purchased";
|
||||
const row = await getOrCreateRow({ creditKey });
|
||||
row.lifetime_consumed = 0;
|
||||
row.purchased_balance = 0;
|
||||
await refundCredit({ creditKey, tier: "core", backend: "hardware" });
|
||||
const after = await getOrCreateRow({ creditKey });
|
||||
assert.equal(after.lifetime_consumed, 0, "must floor at 0, not go negative");
|
||||
assert.equal(after.purchased_balance, 1, "refund lands in purchased when tier is already 0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyTierPromotion", () => {
|
||||
test("Core→paid transfers leftover Core credits to purchased and resets the cycle", async () => {
|
||||
const row = await getOrCreateRow({ creditKey: "inst:promo" });
|
||||
row.lifetime_consumed = 4; // 6 of 10 Core credits unused
|
||||
row.monthly_consumed = 2; // stale paid-counter noise that must be zeroed
|
||||
const promoted = await applyTierPromotion(row, "pro");
|
||||
assert.equal(promoted, true);
|
||||
assert.equal(row.tier_snapshot, "pro");
|
||||
assert.equal(row.purchased_balance, 6, "leftover Core credits carry forward as durable top-up");
|
||||
assert.equal(row.purchased_total_ever, 6);
|
||||
assert.equal(row.monthly_consumed, 0, "promotion starts a fresh monthly cycle");
|
||||
});
|
||||
|
||||
test("idempotent: a second promotion on an already-paid row is a no-op", async () => {
|
||||
const row = await getOrCreateRow({ creditKey: "inst:promo-again" });
|
||||
row.lifetime_consumed = 4;
|
||||
await applyTierPromotion(row, "pro"); // first: fires
|
||||
const purchasedAfterFirst = row.purchased_balance;
|
||||
const promoted = await applyTierPromotion(row, "max"); // second: must bail
|
||||
assert.equal(promoted, false);
|
||||
assert.equal(row.purchased_balance, purchasedAfterFirst, "no second leftover transfer");
|
||||
assert.equal(row.tier_snapshot, "pro", "bails before flipping tier again");
|
||||
});
|
||||
|
||||
test("promoting to Core is a no-op", async () => {
|
||||
const row = await getOrCreateRow({ creditKey: "inst:promo-core" });
|
||||
assert.equal(await applyTierPromotion(row, "core"), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// SSRF guard for user-supplied media URLs (safe-url.js). Uses literal
|
||||
// IPs so the address checks need no DNS / network.
|
||||
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isBlockedAddress,
|
||||
assertPublicHttpUrl,
|
||||
BlockedUrlError,
|
||||
} from "../safe-url.js";
|
||||
|
||||
describe("isBlockedAddress", () => {
|
||||
test("blocks private / loopback / link-local / reserved IPv4", () => {
|
||||
for (const ip of [
|
||||
"127.0.0.1",
|
||||
"10.0.0.5",
|
||||
"172.16.0.1",
|
||||
"172.31.255.255",
|
||||
"192.168.1.1",
|
||||
"169.254.169.254", // cloud metadata
|
||||
"100.64.0.1",
|
||||
"0.0.0.0",
|
||||
"198.18.0.1",
|
||||
"224.0.0.1",
|
||||
"255.255.255.255",
|
||||
]) {
|
||||
assert.equal(isBlockedAddress(ip), true, `${ip} should be blocked`);
|
||||
}
|
||||
});
|
||||
|
||||
test("allows public IPv4 (incl. the /12 boundaries around 172.16/12)", () => {
|
||||
for (const ip of ["8.8.8.8", "1.1.1.1", "172.15.0.1", "172.32.0.1", "93.184.216.34"]) {
|
||||
assert.equal(isBlockedAddress(ip), false, `${ip} should be allowed`);
|
||||
}
|
||||
});
|
||||
|
||||
test("blocks loopback / ULA / link-local / IPv4-mapped IPv6", () => {
|
||||
for (const ip of [
|
||||
"::1",
|
||||
"::",
|
||||
"fe80::1",
|
||||
"febf::1",
|
||||
"fc00::1",
|
||||
"fd12:3456::1",
|
||||
"ff02::1",
|
||||
"::ffff:127.0.0.1",
|
||||
"::ffff:192.168.0.1",
|
||||
]) {
|
||||
assert.equal(isBlockedAddress(ip), true, `${ip} should be blocked`);
|
||||
}
|
||||
});
|
||||
|
||||
test("allows public IPv6", () => {
|
||||
assert.equal(isBlockedAddress("2606:4700:4700::1111"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertPublicHttpUrl", () => {
|
||||
test("rejects non-http(s) schemes", async () => {
|
||||
for (const u of [
|
||||
"file:///etc/passwd",
|
||||
"gopher://x/_",
|
||||
"ftp://h/f",
|
||||
"data:text/plain,hi",
|
||||
]) {
|
||||
await assert.rejects(() => assertPublicHttpUrl(u), BlockedUrlError);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects literal private / metadata IP hosts (no DNS needed)", async () => {
|
||||
for (const u of [
|
||||
"http://127.0.0.1/x",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://[::1]/x",
|
||||
"http://192.168.0.10:9000/a",
|
||||
"https://10.1.2.3/audio.mp3",
|
||||
]) {
|
||||
await assert.rejects(() => assertPublicHttpUrl(u), BlockedUrlError);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects malformed URLs", async () => {
|
||||
await assert.rejects(() => assertPublicHttpUrl("not a url"), BlockedUrlError);
|
||||
});
|
||||
|
||||
test("allows a public literal IP host", async () => {
|
||||
const u = await assertPublicHttpUrl("https://8.8.8.8/audio.mp3");
|
||||
assert.equal(u.hostname, "8.8.8.8");
|
||||
});
|
||||
});
|
||||
@@ -76,4 +76,26 @@ describe("extendUserTier (prepaid periods)", () => {
|
||||
const days = (new Date(renewed.subscription_expires_at).getTime() - Date.now()) / DAY;
|
||||
assert.ok(days > 29.9 && days < 30.1, `fresh ~30 days from now, got ${days}`);
|
||||
});
|
||||
|
||||
test("early renewal PRESERVES the monthly credit counter (no free reset)", async () => {
|
||||
await extendUserTier({ userId: "u4", tier: "pro", periodDays: 30 });
|
||||
const row = await getUserCreditRow("u4");
|
||||
row.monthly_consumed = 7; // simulate credits already spent this cycle
|
||||
row.monthly_gemini_consumed = 3;
|
||||
// Pay early / renew while the subscription is still in force.
|
||||
await extendUserTier({ userId: "u4", tier: "pro", periodDays: 30 });
|
||||
const after = await getUserCreditRow("u4");
|
||||
assert.equal(after.monthly_consumed, 7, "consumed credits must survive an early renewal");
|
||||
assert.equal(after.monthly_gemini_consumed, 3);
|
||||
});
|
||||
|
||||
test("resubscribing AFTER a lapse starts a fresh cycle (counter reset)", async () => {
|
||||
await extendUserTier({ userId: "u5", tier: "pro", periodDays: 30 });
|
||||
const row = await getUserCreditRow("u5");
|
||||
row.monthly_consumed = 9;
|
||||
row.subscription_expires_at = new Date(Date.now() - 5 * DAY).toISOString(); // lapsed
|
||||
await extendUserTier({ userId: "u5", tier: "pro", periodDays: 30 });
|
||||
const after = await getUserCreditRow("u5");
|
||||
assert.equal(after.monthly_consumed, 0, "a lapsed resubscribe starts a clean cycle");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Persistent webhook dedup — the store that stops a settled-payment
|
||||
// webhook duplicate from double-crediting (BTCPay) or double-extending
|
||||
// (Zaprite) when the duplicate straddles a relay restart.
|
||||
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
initWebhookDedup,
|
||||
isWebhookProcessed,
|
||||
markWebhookProcessed,
|
||||
} from "../webhook-dedup.js";
|
||||
|
||||
// The module is a singleton; each test inits a fresh dir to reset state.
|
||||
const freshDir = () => mkdtempSync(path.join(tmpdir(), "relay-dedup-"));
|
||||
|
||||
describe("webhook dedup (persistent)", () => {
|
||||
test("unknown key is not processed; marking makes it processed", async () => {
|
||||
await initWebhookDedup({ dataDir: freshDir() });
|
||||
assert.equal(isWebhookProcessed("store1|inv1"), false);
|
||||
await markWebhookProcessed("store1|inv1");
|
||||
assert.equal(isWebhookProcessed("store1|inv1"), true);
|
||||
});
|
||||
|
||||
// Headline guarantee for this change.
|
||||
test("processed keys survive a restart (reload from disk)", async () => {
|
||||
const dir = freshDir();
|
||||
await initWebhookDedup({ dataDir: dir });
|
||||
await markWebhookProcessed("store1|inv-restart");
|
||||
// Simulate a relay restart: re-init from the SAME data dir.
|
||||
await initWebhookDedup({ dataDir: dir });
|
||||
assert.equal(
|
||||
isWebhookProcessed("store1|inv-restart"),
|
||||
true,
|
||||
"a key marked before restart must still be deduped after restart"
|
||||
);
|
||||
});
|
||||
|
||||
test("BTCPay and Zaprite keys share the store without colliding", async () => {
|
||||
await initWebhookDedup({ dataDir: freshDir() });
|
||||
await markWebhookProcessed("store1|inv9");
|
||||
await markWebhookProcessed("zaprite:order9");
|
||||
assert.equal(isWebhookProcessed("store1|inv9"), true);
|
||||
assert.equal(isWebhookProcessed("zaprite:order9"), true);
|
||||
assert.equal(isWebhookProcessed("zaprite:inv9"), false);
|
||||
});
|
||||
|
||||
test("marking is idempotent", async () => {
|
||||
await initWebhookDedup({ dataDir: freshDir() });
|
||||
await markWebhookProcessed("k");
|
||||
await markWebhookProcessed("k"); // must not throw or duplicate
|
||||
assert.equal(isWebhookProcessed("k"), true);
|
||||
});
|
||||
|
||||
test("stale entries past the retention window are pruned on load", async () => {
|
||||
const dir = freshDir();
|
||||
const ancient = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const recent = new Date().toISOString();
|
||||
writeFileSync(
|
||||
path.join(dir, "processed-webhooks.json"),
|
||||
JSON.stringify({ keys: { "store1|old": ancient, "store1|new": recent } })
|
||||
);
|
||||
await initWebhookDedup({ dataDir: dir });
|
||||
assert.equal(isWebhookProcessed("store1|old"), false, "1y-old key should be pruned");
|
||||
assert.equal(isWebhookProcessed("store1|new"), true, "recent key should survive");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// Persistent dedup of settled-payment webhook deliveries — BTCPay
|
||||
// invoices AND Zaprite orders share this one store. It replaces the
|
||||
// per-process in-memory Sets the two webhook handlers used to keep:
|
||||
// those were cleared on restart, so a duplicate delivery that straddled
|
||||
// a relay restart would double-credit (BTCPay) or double-extend a
|
||||
// subscription (Zaprite). Persisting the processed-keys to disk closes
|
||||
// that window.
|
||||
//
|
||||
// JSON-file backed (single file at <dataDir>/processed-webhooks.json),
|
||||
// same rationale as the credit ledger in credits.js: at most one
|
||||
// mutation per settled payment, so a plain JSON file with serial writes
|
||||
// is plenty.
|
||||
//
|
||||
// Keys are namespaced by their caller so the two rails can't collide in
|
||||
// the shared store: BTCPay uses `<storeId>|<invoiceId>`, Zaprite uses
|
||||
// `zaprite:<orderId>`. We store key → ISO timestamp (not a bare set) so
|
||||
// entries far older than any processor's retry window can be pruned on
|
||||
// load, keeping the file bounded.
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
// Payment processors retry webhook delivery for hours-to-days, never
|
||||
// months. 180 days is safely beyond any retry window, so pruning past
|
||||
// it cannot re-open a double-grant gap.
|
||||
const RETENTION_MS = 180 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let storePath = null;
|
||||
let processed = {}; // key → ISO timestamp
|
||||
let writing = null; // serializes concurrent writes
|
||||
|
||||
export async function initWebhookDedup({ dataDir } = {}) {
|
||||
const dir = dataDir || "/data";
|
||||
storePath = path.join(dir, "processed-webhooks.json");
|
||||
await fs.mkdir(dir, { recursive: true }).catch(() => {});
|
||||
try {
|
||||
const raw = await fs.readFile(storePath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
processed =
|
||||
parsed && typeof parsed === "object" && parsed.keys ? parsed.keys : {};
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
console.warn(
|
||||
`[webhook-dedup] failed to read ${storePath}: ${err.message} — starting empty`
|
||||
);
|
||||
}
|
||||
processed = {};
|
||||
}
|
||||
// Prune anything older than the retention window so the file stays
|
||||
// bounded over the relay's lifetime.
|
||||
const cutoff = Date.now() - RETENTION_MS;
|
||||
let pruned = 0;
|
||||
for (const [k, ts] of Object.entries(processed)) {
|
||||
const t = Date.parse(ts);
|
||||
if (!Number.isFinite(t) || t < cutoff) {
|
||||
delete processed[k];
|
||||
pruned += 1;
|
||||
}
|
||||
}
|
||||
if (pruned > 0) await persist();
|
||||
console.log(
|
||||
`[webhook-dedup] loaded ${Object.keys(processed).length} processed key(s)` +
|
||||
`${pruned ? `, pruned ${pruned} stale` : ""} from ${storePath}`
|
||||
);
|
||||
}
|
||||
|
||||
async function persist() {
|
||||
// Coalesce concurrent writes — mirrors credits.js persist().
|
||||
if (writing) await writing;
|
||||
writing = (async () => {
|
||||
const tmp = storePath + ".tmp";
|
||||
await fs.writeFile(tmp, JSON.stringify({ keys: processed }), { mode: 0o600 });
|
||||
await fs.rename(tmp, storePath);
|
||||
})();
|
||||
try {
|
||||
await writing;
|
||||
} finally {
|
||||
writing = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isWebhookProcessed(key) {
|
||||
return Object.prototype.hasOwnProperty.call(processed, key);
|
||||
}
|
||||
|
||||
// Mark a webhook key processed and persist immediately. Callers invoke
|
||||
// this AFTER the grant/extend succeeds, so a failed grant leaves the key
|
||||
// unmarked and a processor retry can complete it. Idempotent.
|
||||
export async function markWebhookProcessed(key) {
|
||||
if (isWebhookProcessed(key)) return;
|
||||
processed[key] = new Date().toISOString();
|
||||
await persist();
|
||||
}
|
||||
@@ -31,15 +31,15 @@ export const manifest = setupManifest({
|
||||
start: null,
|
||||
stop: null,
|
||||
},
|
||||
// Relay has no REQUIRED dependencies — Gemini is internet-fronted
|
||||
// and the optional Parakeet/Gemma backends are at user-configured
|
||||
// URLs (typically a separate machine on the operator's LAN).
|
||||
//
|
||||
// BTCPay Server is declared optional so the dashboard's "Connect
|
||||
// BTCPay" flow can auto-discover its URL via
|
||||
// sdk.serviceInterface.getAll() when both are installed on the
|
||||
// same Start9 box. When BTCPay is not installed, the relay still
|
||||
// runs fine — only the credit-purchase flow is disabled.
|
||||
// BTCPay Server is a REQUIRED running dependency (optional: false
|
||||
// here, kind: 'running' in dependencies.ts). It's the only payment
|
||||
// rail through which the operator actually gets paid, so the relay
|
||||
// shouldn't run without it. The dependency also wires up the internal
|
||||
// docker hostname (btcpayserver.startos:23000) the relay-to-BTCPay API
|
||||
// calls rely on, and lets the dashboard's "Connect BTCPay" flow
|
||||
// auto-discover the URL via sdk.serviceInterface.getAll(). (Gemini is
|
||||
// internet-fronted and the Parakeet/Gemma backends are at
|
||||
// user-configured LAN URLs, so neither is a StartOS dependency.)
|
||||
dependencies: {
|
||||
btcpayserver: {
|
||||
description: {
|
||||
|
||||
@@ -125,8 +125,10 @@ 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'
|
||||
import { v_0_2_126 } from './v0.2.126'
|
||||
|
||||
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_126,
|
||||
other: [v_0_2_125, 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],
|
||||
})
|
||||
|
||||
@@ -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 }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_126 = VersionInfo.of({
|
||||
version: '0.2.126:0',
|
||||
releaseNotes: {
|
||||
en_US: 'Persist payment-webhook dedup across restarts (no double-credit/double-extend); declare BTCPay a required dependency; scope CORS to /relay/*; add money-path + webhook-dedup tests; doc fixes',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user