Compare commits

...

14 Commits

Author SHA1 Message Date
Keysat 5d0d1b2dd2 docs: note webhook-dedup module + processed-webhooks.json in AGENTS.md 2026-06-15 20:04:47 -05:00
Keysat 238689ddcc Persist payment-webhook dedup; declare BTCPay required; scope CORS
Replace the in-memory dedup Sets in the BTCPay and Zaprite webhook
handlers (and the BTCPay rescan path) with a persistent JSON-backed
store (server/webhook-dedup.js). The in-memory sets were cleared on
restart, so a duplicate webhook delivery straddling a relay restart
could double-credit (BTCPay) or double-extend a subscription (Zaprite).
The store atomically writes /data/processed-webhooks.json, namespaces
keys per rail (storeId|invoiceId vs zaprite:orderId), and prunes
entries older than 180 days (safely beyond any retry window).

Also:
- BTCPay is a required running dependency (operator decision). Config
  was already optional:false/kind:'running'; corrected the contradictory
  "optional" comment in the manifest to match.
- Scope cors() to /relay/* only — off /admin/* and the same-origin
  dashboard, which don't need permissive CORS.
- Add money-path unit tests (commitCredit/refundCredit/applyTierPromotion)
  and webhook-dedup tests (incl. the survives-a-restart guarantee).
- Fix two AGENTS.md auth-doc drifts; refresh Current state.

Version 0.2.125 -> 0.2.126.
2026-06-15 18:15:00 -05:00
Keysat 798a698132 Add Users dashboard tab with per-user balances and credit grants
New cookie-gated "Users" tab on the operator dashboard: a sortable view
of every credit-ledger row (typed cloud/license/install) with computed
remaining/total balances, key filter, and a per-row "grant free credits"
action.

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

Admin-only; no /relay/* client contract change. Tests added in
server/test/admin-credits.test.js (mount the real router over HTTP).
Version bumped 0.2.124 -> 0.2.125.
2026-06-15 16:25:14 -05:00
Keysat 00da92a872 docs: note Gitea remote in Current state 2026-06-15 12:26:56 -05:00
Keysat b10399819b Add inbox-check line; align .gitignore with canonical .claude policy
Cross-repo git-hygiene audit remediation: surface ~/Projects/standards/INBOX.md items at session start, and switch .gitignore to the deny-by-default .claude/* block (shared wiring allow-listed) plus the canonical secrets/env lines — per standards/portability.md.
2026-06-14 12:17:16 -05:00
Keysat e4c6c30ee3 docs: refresh Current state after P1/P2 security pass; move P3+ to ROADMAP 2026-06-13 18:28:27 -05:00
Keysat 3e33728013 Mark three P2 hardening items done in Current state 2026-06-13 18:22:20 -05:00
Keysat 693d72431b Return clean JSON for body-parser/unhandled errors
Malformed JSON request bodies fell through to Express's default error
handler, which renders an HTML page including the local-filesystem stack
trace (info leak). Add a final error-handling middleware: JSON 400 for
entity.parse.failed, 413 for entity.too.large, generic 500 otherwise —
closing the stack-trace leak on every propagated error, not just JSON.
2026-06-13 18:22:00 -05:00
Keysat da1bba2e6b Compare operator key in constant time
resolveIdentity and verifyOperatorKey compared the shared
relay_cloud_operator_key with ===/!==, which short-circuits on the first
differing byte — a timing oracle on a high-value key. Use a
timingSafeEqual-based constantTimeEqual, matching admin-auth.js.
2026-06-13 18:22:00 -05:00
Keysat cbd9748a79 Guard meeting :id against path traversal
saveMeeting/loadMeeting/deleteMeeting built path.join(meetingsDir, id +
'.json') straight from req.params.id, so an admin-authed :id like
'../../etc/passwd' could read/write/delete outside internal-meetings/.
Centralize a meetingPath() helper that strips anything outside
[A-Za-z0-9_-] (mirrors output-store.js) and throws on an empty result;
load/delete catch it as 404/no-op. Add a regression test.
2026-06-13 18:22:00 -05:00
Keysat 54ddaffced Mark P1 work queue done in Current state 2026-06-13 16:23:55 -05:00
Keysat 3a601e166a Bump multer 1.4.5-lts.1 -> ^2.0.1 (DoS CVEs)
multer 1.x is affected by CVE-2025-47944/47935/48997/7338 (malformed
multipart crashes the process / leaks memory). 2.x raises catchable
errors instead. Usage (diskStorage + .single("file")) is unchanged.
Commit the server lockfile so the Dockerfile's npm-ci path pins the fix.
2026-06-13 16:23:26 -05:00
Keysat d2caa98248 Fix credit-counter reset on early subscription renewal
extendUserTier called setUserTier, which unconditionally zeroed
monthly_consumed and re-anchored the cycle. A user who renewed mid-cycle
(or a webhook double-firing across a restart) got their full monthly
allotment back for free. The monthly cycle already rolls on its own
anniversary via ensureRenewalRollover, so renewal must not reset it. Add
resetCycle to setUserTier (default true, preserving operator-grant
behavior); extendUserTier passes false for an in-force subscription and
true only for a brand-new or lapsed one. Add regression tests.
2026-06-13 16:23:26 -05:00
Keysat 8ad7c54da4 Block SSRF on media_url downloads (transcribe-url/summarize-url)
downloadDirect fetched any caller-supplied media_url with redirect-follow
and no host/scheme validation; the route is reachable via a self-chosen
X-Recap-Install-Id, so a caller could probe the operator's LAN or cloud
metadata (169.254.169.254). Add safe-url.js: assertPublicHttpUrl rejects
non-http(s) schemes and hosts resolving to private/loopback/link-local/
reserved ranges, and safeFetch follows redirects manually, re-validating
each hop. Route downloadDirect through it (covers transcribe-url,
summarize-url, and admin-test-run).
2026-06-13 16:23:26 -05:00
26 changed files with 2573 additions and 106 deletions
+8 -2
View File
@@ -26,8 +26,14 @@ ytdlp-cache/
# Local dev secrets # Local dev secrets
.env .env
.env.*
!.env.example
# Claude Code state (worktrees, plans, etc.) — but commit the lazy-load # Claude Code — deny by default (worktrees, plans, local settings stay out),
# rule symlinks under .claude/rules/ (they point into docs/guides/). # allow-list shared wiring (see standards/portability.md).
.claude/* .claude/*
!.claude/rules/ !.claude/rules/
!.claude/agents/
!.claude/commands/
!.claude/skills/
!.claude/settings.json
+23 -38
View File
@@ -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.** 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 ## Stack
- **Server**: Node.js (`type: module`, ES modules). Same dev box as the app (`v25.6.1`); container runtime is whatever the `Dockerfile` pins. - **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`. - **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`. - **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). - **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 ## Commands
@@ -43,8 +46,11 @@ server/
chunked-analyze.js windowed analyze (planWindowsByDuration, runPipelinedAnalysis, …) chunked-analyze.js windowed analyze (planWindowsByDuration, runPipelinedAnalysis, …)
config.js getConfigSnapshot() + relay_* config defaults config.js getConfigSnapshot() + relay_* config defaults
hardware-config.js resolveHardwareConfig() → Spark Control endpoint discovery hardware-config.js resolveHardwareConfig() → Spark Control endpoint discovery
test/ node --test files (speaker-clustering, meeting-speaker-edits, credits) safe-url.js SSRF guard: assertPublicHttpUrl + safeFetch for caller-supplied URLs
public/dashboard.html operator dashboard (meetings detail view + speaker tools) 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 startos/versions/<vN>.ts one file per version + index.ts graph
docs/issues-backlog.md detailed issue log docs/issues-backlog.md detailed issue log
docs/guides/internal-meetings.md diarization / speaker subsystem guide (path-scoped; lazy-loads via .claude/rules/) 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-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. - **`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. - **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). - **`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) ### `/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`) ### `/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`. - **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. - **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`). - **`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 ## 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. - **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. - **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. - **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.
- **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. - **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.
- **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. - **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.
### Work queue — P0/P1 (fix first) - **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. **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)* 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. **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)* 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`.
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)* - **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`.
### 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`.
+10
View File
@@ -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`. - **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`) ## 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. 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
View File
@@ -1044,10 +1044,15 @@
activeTab: (() => { activeTab: (() => {
try { try {
const saved = localStorage.getItem("recap-relay-active-tab"); 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 {} } catch {}
return "overview"; 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. // Jobs-tab state.
jobsData: null, jobsData: null,
jobsLoading: false, jobsLoading: false,
@@ -1176,6 +1181,9 @@
if (state.authed && state.activeTab === "meetings" && !state.meetingsList) { if (state.authed && state.activeTab === "meetings" && !state.meetingsList) {
loadMeetingsList(); loadMeetingsList();
} }
if (state.authed && state.activeTab === "users" && !state.creditsData) {
loadCredits();
}
// Start the Overview auto-refresh poll on boot regardless of // Start the Overview auto-refresh poll on boot regardless of
// current tab — cheap (one fetch every 10s) and ensures the // current tab — cheap (one fetch every 10s) and ensures the
// tab is current the moment the operator switches to it. // tab is current the moment the operator switches to it.
@@ -1460,6 +1468,8 @@
renderSettingsTab(); renderSettingsTab();
} else if (state.activeTab === "meetings") { } else if (state.activeTab === "meetings") {
renderMeetingsTab(); renderMeetingsTab();
} else if (state.activeTab === "users") {
renderUsersTab();
} else { } else {
renderDashboard(); renderDashboard();
} }
@@ -1471,6 +1481,7 @@
return '<div class="tabs">' + return '<div class="tabs">' +
t("overview", "Overview") + t("overview", "Overview") +
t("jobs", "Jobs") + t("jobs", "Jobs") +
t("users", "Users") +
t("meetings", "Internal Meetings") + t("meetings", "Internal Meetings") +
t("settings", "Settings") + t("settings", "Settings") +
'</div>'; '</div>';
@@ -1561,6 +1572,9 @@
if (tab === "meetings") { if (tab === "meetings") {
loadMeetingsList(); loadMeetingsList();
} }
if (tab === "users") {
loadCredits();
}
// Overview tab: re-fetch the dashboard data on EVERY entry (so // Overview tab: re-fetch the dashboard data on EVERY entry (so
// the summaries / errors / perf tables show current state, not // the summaries / errors / perf tables show current state, not
// whatever was last cached) AND start the 10-second auto-refresh // whatever was last cached) AND start the 10-second auto-refresh
@@ -6662,6 +6676,170 @@
'</div>'; '</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) { function table(headers, rows) {
if (rows.length === 0) return '<div class="empty">No data in this range.</div>'; if (rows.length === 0) return '<div class="empty">No data in this range.</div>';
const thead = headers.map(h => '<th>' + esc(h) + '</th>').join(""); const thead = headers.map(h => '<th>' + esc(h) + '</th>').join("");
+27 -11
View File
@@ -741,21 +741,26 @@ export async function addPurchasedCredits({
// stored on the user's credit row (keyed `user:<id>`). Set by the // stored on the user's credit row (keyed `user:<id>`). Set by the
// operator (today) and the self-serve purchase flow (later slice). // operator (today) and the self-serve purchase flow (later slice).
// Operator-set a cloud user's tier. Resets the monthly counters and // Operator-set a cloud user's tier. With `resetCycle` (the default) it
// anchors the renewal to now (so the monthly cycle starts on the grant // starts a fresh monthly cycle anchored to now — so an operator comp
// date), mirroring applyTierPromotion. `expiresAt` is stored for // grant, or a first/lapsed self-serve purchase, begins its allowance on
// reporting / future self-serve billing but NOT auto-enforced in this // the grant date (mirroring applyTierPromotion). A renewal of an
// slice — to revoke, set tier back to "core". // in-force subscription passes `resetCycle: false` so it extends the
export async function setUserTier({ userId, tier, expiresAt = null }) { // 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"); if (!userId) throw new Error("setUserTier: userId required");
const t = tier === "pro" || tier === "max" ? tier : "core"; const t = tier === "pro" || tier === "max" ? tier : "core";
const row = await getOrCreateRow({ creditKey: `user:${userId}` }); const row = await getOrCreateRow({ creditKey: `user:${userId}` });
const now = new Date(); const now = new Date();
row.tier_snapshot = t; row.tier_snapshot = t;
row.monthly_consumed = 0; if (resetCycle) {
row.monthly_gemini_consumed = 0; row.monthly_consumed = 0;
row.last_renewal_at = now.toISOString(); row.monthly_gemini_consumed = 0;
row.anniversary_day = now.getUTCDate(); row.last_renewal_at = now.toISOString();
row.anniversary_day = now.getUTCDate();
}
row.subscription_expires_at = expiresAt || null; row.subscription_expires_at = expiresAt || null;
row.last_active_at = now.toISOString(); row.last_active_at = now.toISOString();
await persist(); 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 // (still-active) expiry — so paying early ADDS time rather than resetting
// it. `periodDays` defaults to 30. Both payment rails (BTCPay + Zaprite) // it. `periodDays` defaults to 30. Both payment rails (BTCPay + Zaprite)
// land here on a settled payment. Returns the updated row. // 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 }) { export async function extendUserTier({ userId, tier, periodDays = 30 }) {
if (!userId) throw new Error("extendUserTier: userId required"); if (!userId) throw new Error("extendUserTier: userId required");
const t = tier === "pro" || tier === "max" ? tier : "core"; 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 const curExp = row.subscription_expires_at
? new Date(row.subscription_expires_at).getTime() ? new Date(row.subscription_expires_at).getTime()
: 0; : 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 base = Math.max(now, Number.isFinite(curExp) ? curExp : 0);
const expiresAt = new Date( const expiresAt = new Date(
base + periodDays * 24 * 60 * 60 * 1000, base + periodDays * 24 * 60 * 60 * 1000,
).toISOString(); ).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). // Read a cloud user's credit row (creates a blank Core row if none yet).
+17 -2
View File
@@ -23,10 +23,25 @@
// A route bills against `creditKey`; for tier it uses the stored row tier // A route bills against `creditKey`; for tier it uses the stored row tier
// (cloud) or `license.tier` (license) — see identityTier(). // (cloud) or `license.tier` (license) — see identityTier().
import { timingSafeEqual } from "crypto";
import { getConfigSnapshot } from "./config.js"; import { getConfigSnapshot } from "./config.js";
import { resolveLicense } from "./keysat-client.js"; import { resolveLicense } from "./keysat-client.js";
import { getCreditKey } from "./credits.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 // 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 // charset so a header can't smuggle a path-ish or oversized key into the
// ledger. // ledger.
@@ -40,7 +55,7 @@ export async function resolveIdentity(req) {
const cfg = await getConfigSnapshot(); const cfg = await getConfigSnapshot();
const expected = (cfg.relay_cloud_operator_key || "").trim(); const expected = (cfg.relay_cloud_operator_key || "").trim();
const presented = (req.header("X-Recap-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( const e = new Error(
"X-Recap-User-Id requires a valid X-Recap-Operator-Key" "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 cfg = await getConfigSnapshot();
const expected = (cfg.relay_cloud_operator_key || "").trim(); const expected = (cfg.relay_cloud_operator_key || "").trim();
const presented = (req.header("X-Recap-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. // The tier to bill/quota at for a resolved identity.
+23 -1
View File
@@ -17,6 +17,7 @@ import { initCredits } from "./credits.js";
import { initAuditLog } from "./audit-log.js"; import { initAuditLog } from "./audit-log.js";
import { initJobCredits } from "./job-credits.js"; import { initJobCredits } from "./job-credits.js";
import { initOutputStore } from "./output-store.js"; import { initOutputStore } from "./output-store.js";
import { initWebhookDedup } from "./webhook-dedup.js";
import { import {
setupAdminAuthMiddleware, setupAdminAuthMiddleware,
setupAdminAuthRoutes, setupAdminAuthRoutes,
@@ -49,9 +50,14 @@ await initCredits({ dataDir: DATA_DIR });
await initJobCredits({ dataDir: DATA_DIR }); await initJobCredits({ dataDir: DATA_DIR });
await initAuditLog({ dataDir: DATA_DIR }); await initAuditLog({ dataDir: DATA_DIR });
await initOutputStore({ dataDir: DATA_DIR }); await initOutputStore({ dataDir: DATA_DIR });
await initWebhookDedup({ dataDir: DATA_DIR });
const app = express(); 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()); app.use(cookieParser());
// Admin auth must run BEFORE the admin routes register so the cookie // Admin auth must run BEFORE the admin routes register so the cookie
@@ -105,6 +111,22 @@ app.get("/", (_req, res) => {
res.redirect("/dashboard.html"); 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"; const HOSTNAME = process.env.HOSTNAME || "0.0.0.0";
app.listen(PORT, HOSTNAME, () => { app.listen(PORT, HOSTNAME, () => {
console.log(`[relay] listening on http://${HOSTNAME}:${PORT}`); console.log(`[relay] listening on http://${HOSTNAME}:${PORT}`);
+1442
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -12,7 +12,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"express": "^4.21.0", "express": "^4.21.0",
"multer": "^1.4.5-lts.1", "multer": "^2.0.1",
"undici": "^6.21.0" "undici": "^6.21.0"
} }
} }
+72 -2
View File
@@ -8,8 +8,8 @@
// action but reachable from the dashboard) // action but reachable from the dashboard)
import express from "express"; import express from "express";
import { getConfigSnapshot, getTierPrices } from "../config.js"; import { getConfigSnapshot, getTierPrices, getTierQuotas } from "../config.js";
import { snapshotAll } from "../credits.js"; import { snapshotAll, computeRemaining, addPurchasedCredits } from "../credits.js";
import { snapshotCache } from "../keysat-client.js"; import { snapshotCache } from "../keysat-client.js";
// snapshotJobs is exported by BOTH ../jobs.js (the in-memory job // snapshotJobs is exported by BOTH ../jobs.js (the in-memory job
// tracker) and ../job-credits.js (the credit-ledger). They return // 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 fs from "fs/promises";
import path from "path"; 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 }) { export function adminRouter({ dataDir }) {
const router = express.Router(); 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) => { router.get("/config", async (_req, res) => {
const cfg = await getConfigSnapshot(); const cfg = await getConfigSnapshot();
const hw = await (await import("../hardware-config.js")).resolveHardwareConfig(cfg); const hw = await (await import("../hardware-config.js")).resolveHardwareConfig(cfg);
+17 -19
View File
@@ -49,18 +49,16 @@ import {
BtcPayError, BtcPayError,
} from "../btcpay-client.js"; } from "../btcpay-client.js";
import { recordCall } from "../audit-log.js"; import { recordCall } from "../audit-log.js";
import { isWebhookProcessed, markWebhookProcessed } from "../webhook-dedup.js";
import { envelope, errorEnvelope } from "./envelope.js"; import { envelope, errorEnvelope } from "./envelope.js";
// In-memory dedup of processed BTCPay invoice ids. BTCPay retries // Dedup of processed BTCPay invoice ids. BTCPay retries webhook
// webhook deliveries on non-2xx responses or network errors, so the // deliveries on non-2xx responses or network errors, so the same
// same InvoiceSettled event can land more than once. We don't want // InvoiceSettled event can land more than once — we don't want to grant
// to grant credits twice for one paid invoice. // credits twice for one paid invoice. Backed by the persistent
// // webhook-dedup store (../webhook-dedup.js) so a duplicate straddling a
// Cleared on relay restart, which means an unlucky webhook duplicate // relay restart can't double-credit. Keys are namespaced
// straddling a restart would double-credit. Acceptable tradeoff for // `<storeId>|<invoiceId>` to share the store with the Zaprite rail.
// 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();
export function creditsRouter() { export function creditsRouter() {
const router = express.Router(); const router = express.Router();
@@ -452,7 +450,7 @@ export function creditsRouter() {
} }
const dedupKey = `${cfg.relay_btcpay_store_id}|${invoiceId}`; const dedupKey = `${cfg.relay_btcpay_store_id}|${invoiceId}`;
if (processedInvoices.has(dedupKey)) { if (isWebhookProcessed(dedupKey)) {
return res return res
.status(200) .status(200)
.json({ ok: true, ignored: "already_processed", invoiceId }); .json({ ok: true, ignored: "already_processed", invoiceId });
@@ -487,7 +485,7 @@ export function creditsRouter() {
const subTier = meta.tier; const subTier = meta.tier;
const periodDays = Number(meta.period_days) || 30; const periodDays = Number(meta.period_days) || 30;
if (!subUserId || (subTier !== "pro" && subTier !== "max")) { if (!subUserId || (subTier !== "pro" && subTier !== "max")) {
processedInvoices.add(dedupKey); await markWebhookProcessed(dedupKey);
return res return res
.status(200) .status(200)
.json({ ok: true, ignored: "bad_tier_metadata", invoiceId }); .json({ ok: true, ignored: "bad_tier_metadata", invoiceId });
@@ -498,7 +496,7 @@ export function creditsRouter() {
tier: subTier, tier: subTier,
periodDays, periodDays,
}); });
processedInvoices.add(dedupKey); await markWebhookProcessed(dedupKey);
console.log( console.log(
`[btcpay/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (invoice ${invoiceId}) → expires ${row.subscription_expires_at}`, `[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 // Not ours — could be an invoice from another product on the
// same store. Mark processed so we don't keep retrying, and // same store. Mark processed so we don't keep retrying, and
// 200 so BTCPay stops calling. // 200 so BTCPay stops calling.
processedInvoices.add(dedupKey); await markWebhookProcessed(dedupKey);
return res return res
.status(200) .status(200)
.json({ ok: true, ignored: "no_recap_metadata" }); .json({ ok: true, ignored: "no_recap_metadata" });
@@ -541,7 +539,7 @@ export function creditsRouter() {
creditKey, creditKey,
amount: credits, amount: credits,
}); });
processedInvoices.add(dedupKey); await markWebhookProcessed(dedupKey);
await recordCall({ await recordCall({
install_id: installId, install_id: installId,
license_fingerprint: buyerFp, license_fingerprint: buyerFp,
@@ -614,8 +612,8 @@ function rewriteCheckoutUrl(url, browserBase) {
// Recovery: when the BTCPay webhook URL was broken and paid // Recovery: when the BTCPay webhook URL was broken and paid
// invoices never got credited, this scans BTCPay's recent settled // invoices never got credited, this scans BTCPay's recent settled
// invoices and grants credits for ones the relay hasn't processed. // invoices and grants credits for ones the relay hasn't processed.
// Idempotent via the same processedInvoices dedup the webhook uses, // Idempotent via the same persistent webhook-dedup store the webhook
// so re-running is safe. // uses, so re-running is safe.
// //
// Exported so the admin route in btcpay-setup.js can call it. Not // Exported so the admin route in btcpay-setup.js can call it. Not
// exposed via /relay/* because it's operator-initiated, not buyer. // exposed via /relay/* because it's operator-initiated, not buyer.
@@ -660,7 +658,7 @@ export async function rescanSettledInvoices() {
const details = []; const details = [];
for (const invoice of invoices || []) { for (const invoice of invoices || []) {
const dedupKey = `${cfg.relay_btcpay_store_id}|${invoice.id}`; const dedupKey = `${cfg.relay_btcpay_store_id}|${invoice.id}`;
if (processedInvoices.has(dedupKey)) { if (isWebhookProcessed(dedupKey)) {
alreadyProcessed++; alreadyProcessed++;
continue; continue;
} }
@@ -684,7 +682,7 @@ export async function rescanSettledInvoices() {
creditKey, creditKey,
amount: credits, amount: credits,
}); });
processedInvoices.add(dedupKey); await markWebhookProcessed(dedupKey);
await recordCall({ await recordCall({
install_id: installId, install_id: installId,
license_fingerprint: buyerFp, license_fingerprint: buyerFp,
+15 -3
View File
@@ -77,19 +77,31 @@ async function ensureMeetingsDir(dataDir) {
await fs.mkdir(meetingsDir(dataDir), { recursive: true }).catch(() => {}); 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 ────────────────────────────────────────────────── // ─── Storage layer ──────────────────────────────────────────────────
async function saveMeeting(dataDir, id, record) { async function saveMeeting(dataDir, id, record) {
await ensureMeetingsDir(dataDir); 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), { await fs.writeFile(filePath, JSON.stringify(record, null, 2), {
mode: 0o600, mode: 0o600,
}); });
} }
async function loadMeeting(dataDir, id) { async function loadMeeting(dataDir, id) {
const filePath = path.join(meetingsDir(dataDir), `${id}.json`);
try { try {
const filePath = meetingPath(dataDir, id);
const raw = await fs.readFile(filePath, "utf8"); const raw = await fs.readFile(filePath, "utf8");
const rec = JSON.parse(raw); const rec = JSON.parse(raw);
// Retroactive chunk-contiguity backfill must run BEFORE the // Retroactive chunk-contiguity backfill must run BEFORE the
@@ -239,8 +251,8 @@ async function listMeetings(dataDir) {
} }
async function deleteMeeting(dataDir, id) { async function deleteMeeting(dataDir, id) {
const filePath = path.join(meetingsDir(dataDir), `${id}.json`);
try { try {
const filePath = meetingPath(dataDir, id);
await fs.unlink(filePath); await fs.unlink(filePath);
return true; return true;
} catch { } catch {
+7 -2
View File
@@ -50,6 +50,7 @@ import { calcGeminiCost } from "../pricing.js";
import { getAudioDurationSeconds } from "../audio-meta.js"; import { getAudioDurationSeconds } from "../audio-meta.js";
import { resolveHardwareConfig } from "../hardware-config.js"; import { resolveHardwareConfig } from "../hardware-config.js";
import { reportHealthEvent } from "../spark-control-events.js"; import { reportHealthEvent } from "../spark-control-events.js";
import { safeFetch } from "../safe-url.js";
import { import {
createJob, createJob,
markRunning, markRunning,
@@ -97,8 +98,12 @@ function guessMimeFromExt(filePath) {
// would exceed MAX_DOWNLOAD_BYTES. Returns { filePath, bytes, // would exceed MAX_DOWNLOAD_BYTES. Returns { filePath, bytes,
// mimeType }. // mimeType }.
export async function downloadDirect(url, tmpDir) { export async function downloadDirect(url, tmpDir) {
const res = await fetch(url, { // safeFetch is the SSRF choke point: it rejects non-http(s) schemes
redirect: "follow", // 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), signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
}); });
if (!res.ok) { if (!res.ok) {
+11 -13
View File
@@ -15,16 +15,14 @@ import express from "express";
import { extendUserTier } from "../credits.js"; import { extendUserTier } from "../credits.js";
import { getZapriteConfig } from "../config.js"; import { getZapriteConfig } from "../config.js";
import { getOrder, isOrderPaid, orderIdFromWebhook } from "../zaprite-client.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 // Dedup of fully-processed orders (mirrors the BTCPay handler). Zaprite
// processedInvoices). Zaprite retries on non-200, and may fire multiple // retries on non-200, and may fire multiple events per order, so we
// events per order, so we guard the grant. Cleared on restart — a // guard the grant. Backed by the persistent webhook-dedup store
// re-delivered webhook after restart re-fetches + re-grants, but // (../webhook-dedup.js) so a re-delivered webhook straddling a relay
// extendUserTier is keyed per order via this set within a process; a // restart can't double-extend a subscription. Keys are namespaced
// duplicate grant across a restart is the same harmless "extend by one // `zaprite:<orderId>` to share the store with the BTCPay rail.
// period" the operator-comp path already tolerates. (Acceptable: card
// double-fires across a restart are vanishingly rare.)
const processedZaprite = new Set();
export function zapriteWebhookRouter() { export function zapriteWebhookRouter() {
const router = express.Router(); const router = express.Router();
@@ -49,7 +47,7 @@ export function zapriteWebhookRouter() {
return res.status(200).json({ ok: true, ignored: "no_order_id" }); return res.status(200).json({ ok: true, ignored: "no_order_id" });
} }
const dedupKey = `zaprite:${orderId}`; const dedupKey = `zaprite:${orderId}`;
if (processedZaprite.has(dedupKey)) { if (isWebhookProcessed(dedupKey)) {
return res return res
.status(200) .status(200)
.json({ ok: true, ignored: "already_processed", orderId }); .json({ ok: true, ignored: "already_processed", orderId });
@@ -91,7 +89,7 @@ export function zapriteWebhookRouter() {
const meta = order.metadata || {}; const meta = order.metadata || {};
if (meta.product !== "recap_tier_subscription") { if (meta.product !== "recap_tier_subscription") {
processedZaprite.add(dedupKey); await markWebhookProcessed(dedupKey);
return res return res
.status(200) .status(200)
.json({ ok: true, ignored: "not_a_tier_order", orderId }); .json({ ok: true, ignored: "not_a_tier_order", orderId });
@@ -100,7 +98,7 @@ export function zapriteWebhookRouter() {
const subTier = meta.tier; const subTier = meta.tier;
const periodDays = Number(meta.period_days) || 30; const periodDays = Number(meta.period_days) || 30;
if (!subUserId || (subTier !== "pro" && subTier !== "max")) { if (!subUserId || (subTier !== "pro" && subTier !== "max")) {
processedZaprite.add(dedupKey); await markWebhookProcessed(dedupKey);
return res return res
.status(200) .status(200)
.json({ ok: true, ignored: "bad_tier_metadata", orderId }); .json({ ok: true, ignored: "bad_tier_metadata", orderId });
@@ -112,7 +110,7 @@ export function zapriteWebhookRouter() {
tier: subTier, tier: subTier,
periodDays, periodDays,
}); });
processedZaprite.add(dedupKey); await markWebhookProcessed(dedupKey);
console.log( console.log(
`[zaprite/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (order ${orderId}) → expires ${row.subscription_expires_at}`, `[zaprite/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (order ${orderId}) → expires ${row.subscription_expires_at}`,
); );
+158
View File
@@ -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 fe80febf.
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");
}
+106
View File
@@ -0,0 +1,106 @@
// The /admin/credits read view + /admin/credits/grant action behind the
// Users dashboard tab. Mounts the REAL admin router (sans the cookie
// auth middleware, which lives in index.js) over an ephemeral HTTP
// server so the handler's validation runs for real, not just the
// underlying ledger primitives.
import { test, describe, before, after } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import express from "express";
import { initCredits, setUserTier, addPurchasedCredits } from "../credits.js";
import { adminRouter } from "../routes/admin.js";
let baseUrl;
let server;
const getCredits = async () => {
const r = await fetch(`${baseUrl}/admin/credits`, { cache: "no-store" });
return { status: r.status, body: await r.json() };
};
const grant = async (payload) => {
const r = await fetch(`${baseUrl}/admin/credits/grant`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return { status: r.status, body: await r.json().catch(() => ({})) };
};
const rowFor = (body, key) => body.rows.find((r) => r.credit_key === key);
describe("/admin/credits + /admin/credits/grant", () => {
before(async () => {
await initCredits({ dataDir: mkdtempSync(path.join(tmpdir(), "relay-admincred-")) });
// Seed one row of each ledger-key shape so the type derivation and
// the per-tier balance math are both exercised. Default quotas:
// Core lifetime=10, Pro monthly=50.
await setUserTier({ userId: "alice", tier: "pro" }); // user:alice
await addPurchasedCredits({ creditKey: "inst:bob", amount: 5 }); // Core install
await addPurchasedCredits({ creditKey: "lic:deadbeefdeadbeef", amount: 2 }); // license pool
const app = express();
app.use("/admin", adminRouter({ dataDir: "/tmp" }));
await new Promise((resolve) => { server = app.listen(0, resolve); });
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
after(() => { server?.close(); });
test("GET /credits derives a type from the credit-key prefix", async () => {
const { status, body } = await getCredits();
assert.equal(status, 200);
assert.equal(rowFor(body, "user:alice").type, "cloud");
assert.equal(rowFor(body, "inst:bob").type, "install");
assert.equal(rowFor(body, "lic:deadbeefdeadbeef").type, "license");
});
test("GET /credits computes remaining/total (not raw counters)", async () => {
const { body } = await getCredits();
const alice = rowFor(body, "user:alice"); // Pro, monthly 50, nothing spent
assert.equal(alice.remaining, 50);
assert.equal(alice.purchased, 0);
assert.equal(alice.total, 50);
const bob = rowFor(body, "inst:bob"); // Core lifetime 10 + 5 purchased
assert.equal(bob.remaining, 10);
assert.equal(bob.purchased, 5);
assert.equal(bob.total, 15);
});
test("POST /grant adds to the purchased bucket and returns the new balance", async () => {
const { status, body } = await grant({ credit_key: "inst:bob", amount: 7 });
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.equal(body.granted, 7);
assert.equal(body.purchased_balance, 12); // 5 seeded + 7 granted
// ...and the read view reflects it (purchased 12, total 10 + 12).
const { body: after } = await getCredits();
const bob = rowFor(after, "inst:bob");
assert.equal(bob.purchased, 12);
assert.equal(bob.total, 22);
});
test("POST /grant rejects non-positive / non-integer amounts", async () => {
for (const amount of [0, -5, 1.5, "x"]) {
const { status } = await grant({ credit_key: "inst:bob", amount });
assert.equal(status, 400, `amount=${amount} should be 400`);
}
});
test("POST /grant rejects an absurdly large amount (fat-finger guard)", async () => {
const { status } = await grant({ credit_key: "inst:bob", amount: 2_000_000 });
assert.equal(status, 400);
});
test("POST /grant 400s on missing credit_key, 404s on an unknown one", async () => {
assert.equal((await grant({ amount: 5 })).status, 400);
const unknown = await grant({ credit_key: "inst:nobody", amount: 5 });
assert.equal(unknown.status, 404);
// The unknown key must NOT have been created as a side effect.
const { body } = await getCredits();
assert.equal(rowFor(body, "inst:nobody"), undefined);
});
});
+34
View File
@@ -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/);
}
});
});
+111
View File
@@ -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);
});
});
+90
View File
@@ -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");
});
});
+22
View File
@@ -76,4 +76,26 @@ describe("extendUserTier (prepaid periods)", () => {
const days = (new Date(renewed.subscription_expires_at).getTime() - Date.now()) / DAY; 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}`); 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");
});
}); });
+70
View File
@@ -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");
});
});
+93
View File
@@ -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();
}
+9 -9
View File
@@ -31,15 +31,15 @@ export const manifest = setupManifest({
start: null, start: null,
stop: null, stop: null,
}, },
// Relay has no REQUIRED dependencies — Gemini is internet-fronted // BTCPay Server is a REQUIRED running dependency (optional: false
// and the optional Parakeet/Gemma backends are at user-configured // here, kind: 'running' in dependencies.ts). It's the only payment
// URLs (typically a separate machine on the operator's LAN). // rail through which the operator actually gets paid, so the relay
// // shouldn't run without it. The dependency also wires up the internal
// BTCPay Server is declared optional so the dashboard's "Connect // docker hostname (btcpayserver.startos:23000) the relay-to-BTCPay API
// BTCPay" flow can auto-discover its URL via // calls rely on, and lets the dashboard's "Connect BTCPay" flow
// sdk.serviceInterface.getAll() when both are installed on the // auto-discover the URL via sdk.serviceInterface.getAll(). (Gemini is
// same Start9 box. When BTCPay is not installed, the relay still // internet-fronted and the Parakeet/Gemma backends are at
// runs fine — only the credit-purchase flow is disabled. // user-configured LAN URLs, so neither is a StartOS dependency.)
dependencies: { dependencies: {
btcpayserver: { btcpayserver: {
description: { description: {
+4 -2
View File
@@ -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_122 } from './v0.2.122'
import { v_0_2_123 } from './v0.2.123' import { v_0_2_123 } from './v0.2.123'
import { v_0_2_124 } from './v0.2.124' 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({ export const versionGraph = VersionGraph.of({
current: v_0_2_124, current: v_0_2_126,
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], 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],
}) })
+12
View File
@@ -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 }) => {},
},
})
+12
View File
@@ -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 }) => {},
},
})