Files
recap/docs/core-decoupling-plan.md
Keysat 0ae59f3550 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
2026-06-13 14:25:05 -05:00

8.6 KiB
Raw Permalink Blame History

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 tierPOST /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/)

  1. 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.
  2. 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.
  3. 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.)
  4. 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 → TierMax (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).