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:
@@ -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
|
||||
|
||||
~**2–3 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).
|
||||
Reference in New Issue
Block a user