Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling

Introduces RECAP_MODE=multi alongside single-mode self-host:
- Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool,
  anonymous trial minting with per-IP/-64 caps
- Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite),
  prepaid 30-day periods, expiry-reminder emails
- Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id
- SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single
- StartOS actions/versions through 0.2.155
This commit is contained in:
Keysat
2026-06-13 14:25:05 -05:00
parent db580abad7
commit 0ae59f3550
176 changed files with 23823 additions and 803 deletions
+175
View File
@@ -0,0 +1,175 @@
# Core Decoupling — Implementation Plan (relay-owns)
**Status:****Implemented + build-ready, 2026-06-04.** Both sides code-
complete, typecheck/syntax clean, unit tests green. Relay bumped to
`0.2.119`, Recaps app to `0.2.143`. Not yet installed/configured on-device —
see "Install + configure runbook" at the bottom.
Scoped slice of `architecture-simplification-plan.md`, with the May plan's
"Recaps-owns billing" reversed per Grant's decision.
## Decisions locked (2026-06-03)
1. **The Recap Relay owns the Pro/Max subscription**, keyed by the Recaps
**user-id** (not a Keysat license). Recaps reads each user's tier from
the relay to gate features.
2. **No credit-pool migration** — no real customers yet; clean cutover.
3. **Keysat leaves the cloud path entirely.** A cloud user has no license.
Keysat/licenses remain ONLY for the (future) self-hosted-operator case.
4. **Server auth = a shared "operator key."** The `recaps.cc` server proves
itself to the relay with a shared secret; it then vouches for its users
via `X-Recap-User-Id`.
5. **Self-serve subscription purchase is DEFERRED.** For this slice, tiers
are **operator-set** (Grant grants Pro/Max). Self-serve BTCPay/card
subscription buying + expiry + renewal = the later "payment" slice.
## Goal (one sentence)
Replace "every cloud relay request carries the user's Keysat license" with
"the `recaps.cc` server authenticates once with an operator key and passes
the user's account-id; the relay tracks that user's tier + credits."
---
## How it works after the change
```
Cloud user's browser
│ (logged-in Recaps session cookie)
recaps.cc server ──reads user's tier from relay, gates features──┐
│ POST /relay/<...> │
│ X-Recap-User-Id: <recaps user id> │
│ X-Recap-Operator-Key: <shared secret> ← proves it's │
│ (NO per-user license bearer) Grant's server│
▼ │
Recap Relay │
• validates operator key → trusts the user-id │
• credit pool keyed by user:<id> │
• stores that user's TIER (+ optional expiry) on the pool ─────┘
• applies the tier's monthly credit quota
```
Self-hosted operators are unchanged: they send a license bearer and no
`X-Recap-User-Id`, so they keep the existing `lic:`/`inst:` path.
---
## Relay changes (recap-relay/)
1. **Identity resolver** (new helper used by every route in place of
`resolveLicense(auth)` + raw installId): if `X-Recap-User-Id` is present
AND `X-Recap-Operator-Key` matches config → identity =
`{ creditKey: "user:<id>", source: "cloud" }`. Else existing license/
install path (`credits.js` `getCreditKey` / `resolveLicense`).
2. **Tier-of-record on the pool** (`credits.js` ledger row): make `tier`
(+ optional `subscription_expires_at`) an authoritative, persisted field
for `user:` pools, instead of reading tier from a license each request.
Quota math (`getTierQuotas`) keys off it as today.
3. **Operator endpoint to set a user's tier**
`POST /admin/users/:userId/tier { tier, expires_at? }` (admin-auth
gated). This is how tiers get set in this slice; the future self-serve
purchase flow writes the same field.
4. **Report tier + balance for a user-id** — extend `/relay/balance` (and
the status surface) to answer for the `user:<id>` identity so Recaps can
read it.
5. **Config:** `relay_cloud_operator_key` (+ a StartOS action to set it,
Phase 1.5).
## Recaps changes (recap/)
6. **Cloud relay identity** (`providers/index.js` `pickRelayIdentity` +
`providers/relay.js` `buildHeaders`): multi-mode cloud user → send
`X-Recap-User-Id` + `X-Recap-Operator-Key` (from server config), DROP the
user-license bearer. Single-mode / self-hosted unchanged.
7. **Entitlement checks read the relay-reported tier**
(`license-middleware.js` multi-mode branch, `tts-routes.js`
`userHasTtsAccess`): derive tier from what the relay reports for this
user (cached on `req.user` / relay-status), not from a parsed license.
Single-mode keeps using the operator `LIC`.
8. **Stop attaching a Keysat license at cloud signup**
(`license-purchase.js`): cloud accounts no longer get a license. (The
existing flow stays available for the self-hosted operator-license case
only.)
9. **Config:** `recap_relay_operator_key` (server-side; never sent to the
browser).
---
## Explicitly deferred (later "payment" slice)
Self-serve Pro/Max subscription purchase (monthly BTCPay/card, expiry,
renewal emails, cancel) · removing the free signed-in tier · "Take Recaps
home" rework. None of these block the decoupling; tiers are operator-set
until then.
## Testing / rollout
1. Relay: identity resolver — cloud (valid key)→`user:` ; cloud (bad/no
key)→reject/fallback ; self-hosted→`lic:`/`inst:`.
2. Relay: operator-set tier → `/relay/balance` reports it → metered call
decrements the `user:` pool at the right quota.
3. Recaps: feature gates (clips, subscriptions, TTS) follow the relay tier.
4. Ship relay first (accepts both old + new), then Recaps cutover. Verify a
self-hosted-style license request still works. Both via `make install` /
sideload — **no registry deploys.**
## Effort
~**23 focused days** (smaller than the migration-laden version): ~1 on the
relay (resolver, tier-on-pool, operator endpoint, config), ~1 on Recaps
(headers, tier-read, gate rewiring), ~0.5 testing.
## Sequencing (resolved 2026-06-03)
Core decoupling ships first with **operator-set tiers**; self-serve
subscription purchase is the immediate next slice. Rationale: land the
structural de-licensing on its own and verify it, then add money-handling
code on a proven foundation rather than entangling a refactor with new
payment flows. No real customers yet, so no cost to this ordering.
---
## What landed (2026-06-04)
**Relay (`recap-relay/`, → 0.2.119):** `identity.js` resolver +
`verifyOperatorKey`; `credits.js` `setUserTier`/`getUserCreditRow` +
`creditKey` threading; `job-credits.js`/`envelope.js` `creditKey`;
`routes/user-tier.js` (`POST`/`GET /relay/user-tier`, operator-key authed);
balance/tts/transcribe/analyze/transcribe-url/summarize-url use
`resolveIdentity`; `config.js` `relay_cloud_operator_key`; admin Settings
expose it as a masked **"Cloud operator key"** field (dashboard +
`PUT /admin/settings`).
**Recaps (`recap/`, → 0.2.143):** `db.js` `users.tier` column +
`migrateUsersTier`; `relay-state.js` `computeCreditKey` keys `user:<id>`;
`providers/index.js` `pickRelayIdentity` emits the cloud identity for paid
users; `providers/relay.js` sends `X-Recap-User-Id`+`X-Recap-Operator-Key`
and adds `setRelayUserTier`/`getRelayUserTier`; `license.js` `viewForTier`;
`license-middleware.js` `/api/license-status` derives the view from
`req.user.tier`; `tts-routes.js` gate reads `req.user.tier`; **3 gates that
keyed off `!keysat_license` now exclude paid-tier users** so they aren't
misrouted to the free-tenant `tenant_credits` path
(`/api/relay/status` display, `/api/process` gate+debit); `config.js` polls
`recap_relay_operator_key` into a live binding; `relay-default.js`
`getRelayOperatorKey` reads env→live-binding; StartOS **"Set Relay Operator
Key"** action + config field; operator **Tenants panel** gets a per-row
tier badge + **Tier** selector (`POST /api/admin/tenants/:id/tier`, which
writes the relay first then caches `users.tier`).
## Install + configure runbook
1. `cd recap-relay && make x86 && make install` (relay 0.2.119). **Never**
`make deploy`/`redeploy` for the relay.
2. Relay dashboard → Settings → Endpoints & credentials → **Cloud operator
key** → paste a fresh secret (`openssl rand -hex 32`). Save.
3. `cd recap && make x86 && make install` (app 0.2.143).
4. Recaps StartOS → Actions → **Set Relay Operator Key** → paste the **same**
secret. (Picked up within one config poll — no restart.)
5. Sign in as operator → **Tenants** → open a user's row → **Tier****Max**
(or Pro). This writes the relay `user:<id>` tier, then caches `users.tier`.
A 502 here means the two operator keys don't match — fix + retry.
6. As that user: confirm the **MAX/PRO badge**, the **Listen** (TTS) button,
that a summarize run is metered against the relay `user:<id>` pool (not
`tenant_credits`), and that `/api/relay/status` shows the relay balance.
7. Regression: a self-hosted-style license request still works (license/
install path untouched — additive).