# 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).
