# 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: │ │ X-Recap-Operator-Key: ← 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: │ • 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:", 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:` 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:`; `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:` 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:` 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).