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
8.6 KiB
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)
- 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.
- No credit-pool migration — no real customers yet; clean cutover.
- Keysat leaves the cloud path entirely. A cloud user has no license. Keysat/licenses remain ONLY for the (future) self-hosted-operator case.
- Server auth = a shared "operator key." The
recaps.ccserver proves itself to the relay with a shared secret; it then vouches for its users viaX-Recap-User-Id. - 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/)
- Identity resolver (new helper used by every route in place of
resolveLicense(auth)+ raw installId): ifX-Recap-User-Idis present ANDX-Recap-Operator-Keymatches config → identity ={ creditKey: "user:<id>", source: "cloud" }. Else existing license/ install path (credits.jsgetCreditKey/resolveLicense). - Tier-of-record on the pool (
credits.jsledger row): maketier(+ optionalsubscription_expires_at) an authoritative, persisted field foruser:pools, instead of reading tier from a license each request. Quota math (getTierQuotas) keys off it as today. - 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. - Report tier + balance for a user-id — extend
/relay/balance(and the status surface) to answer for theuser:<id>identity so Recaps can read it. - Config:
relay_cloud_operator_key(+ a StartOS action to set it, Phase 1.5).
Recaps changes (recap/)
- Cloud relay identity (
providers/index.jspickRelayIdentity+providers/relay.jsbuildHeaders): multi-mode cloud user → sendX-Recap-User-Id+X-Recap-Operator-Key(from server config), DROP the user-license bearer. Single-mode / self-hosted unchanged. - Entitlement checks read the relay-reported tier
(
license-middleware.jsmulti-mode branch,tts-routes.jsuserHasTtsAccess): derive tier from what the relay reports for this user (cached onreq.user/ relay-status), not from a parsed license. Single-mode keeps using the operatorLIC. - 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.) - 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
- Relay: identity resolver — cloud (valid key)→
user:; cloud (bad/no key)→reject/fallback ; self-hosted→lic:/inst:. - Relay: operator-set tier →
/relay/balancereports it → metered call decrements theuser:pool at the right quota. - Recaps: feature gates (clips, subscriptions, TTS) follow the relay tier.
- 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
cd recap-relay && make x86 && make install(relay 0.2.119). Nevermake deploy/redeployfor the relay.- Relay dashboard → Settings → Endpoints & credentials → Cloud operator
key → paste a fresh secret (
openssl rand -hex 32). Save. cd recap && make x86 && make install(app 0.2.143).- Recaps StartOS → Actions → Set Relay Operator Key → paste the same secret. (Picked up within one config poll — no restart.)
- Sign in as operator → Tenants → open a user's row → Tier → Max
(or Pro). This writes the relay
user:<id>tier, then cachesusers.tier. A 502 here means the two operator keys don't match — fix + retry. - As that user: confirm the MAX/PRO badge, the Listen (TTS) button,
that a summarize run is metered against the relay
user:<id>pool (nottenant_credits), and that/api/relay/statusshows the relay balance. - Regression: a self-hosted-style license request still works (license/ install path untouched — additive).