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,261 @@
|
||||
# Recaps Architecture Simplification — Plan of Record
|
||||
|
||||
**Status:** Agreed direction, 2026-05-19. Not yet implemented.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The current Recaps architecture has the Keysat license doing too many jobs.
|
||||
For a cloud Pro/Max tenant, the license is acting as: (a) the entitlement
|
||||
check, (b) the relay credit-pool key, (c) the subscription-expiry source,
|
||||
AND (d) the take-it-home portability token. Only (d) actually requires a
|
||||
cryptographically-signed token. The other three are accidental — the
|
||||
license just happens to carry that data because Keysat was already minting
|
||||
license tokens during the MVP.
|
||||
|
||||
This doc captures the agreed simplification so we can revisit later before
|
||||
implementation.
|
||||
|
||||
---
|
||||
|
||||
## The three products, cleanly separated
|
||||
|
||||
**Recap Relay** — Backend compute service. AI provider routing
|
||||
(Gemini / Claude / Whisper / Ollama / etc.), credit ledger, BTCPay-backed
|
||||
top-ups. Runs on Grant's StartOS. Sold as a service (subscription gives
|
||||
monthly credit allotment + a la carte top-ups).
|
||||
|
||||
**Recaps** — Frontend cloud SaaS for summarizing podcasts/videos. People
|
||||
sign up, pay a subscription, and use the relay for compute. Hosted at
|
||||
`recaps.cc`. Also freely available as a `.s9pk` for self-hosting.
|
||||
|
||||
**Keysat** — Standalone licensing-as-a-service software, separately
|
||||
monetized as a B2B product. Recaps happens to use Keysat for the one
|
||||
specific case of minting a portable license token when a cloud user
|
||||
clicks "Take Recaps home." Otherwise unrelated to Recaps day-to-day.
|
||||
|
||||
These are three products that happen to share an author. Tangling them is
|
||||
what made the current architecture confusing.
|
||||
|
||||
---
|
||||
|
||||
## What each layer owns after the simplification
|
||||
|
||||
| Concern | Lives in | Why |
|
||||
|---|---|---|
|
||||
| Subscription tier + `expires_at` | **Recaps DB** (`users.tier`, `users.subscription_expires_at`) | Billing state belongs with the billing surface. Zaprite/BTCPay webhooks fire at Recaps and update one row. |
|
||||
| Credit balance (remaining/consumed) | **Relay's ledger** (`credits.json`) | Compute happens at the relay. The relay knows when N credits were actually spent on Gemini tokens. Race conditions get ugly if billing and consumption drift. |
|
||||
| Purchased credit top-ups (one-shot Lightning) | **Relay's ledger** | BTCPay webhook → relay → bump `purchased_balance` on the relevant pool. Unchanged from today. |
|
||||
| Monthly tier allotment | **Relay computes** from tier header passed by Recaps | Recaps sends `X-Recap-User-Tier: pro`; relay applies its quota config (`pro.monthly = 50`). |
|
||||
| Per-user identity at the relay | Keyed by `user:<recaps_user_id>` (cloud) or `lic:<fp>` (self-hosted) | Removes the license-as-credential coupling for cloud requests. License only matters for self-hosted. |
|
||||
|
||||
### The header change
|
||||
|
||||
```
|
||||
Cloud Recaps → Relay (today):
|
||||
Authorization: Bearer <user's LIC1 token>
|
||||
|
||||
Cloud Recaps → Relay (after simplification):
|
||||
X-Recap-User-Id: <recaps_user_id>
|
||||
X-Recap-User-Tier: pro
|
||||
Authorization: Bearer <OPERATOR's LIC1> ← proves THIS Recaps server is authorized
|
||||
```
|
||||
|
||||
The operator's bearer token is still needed because the relay needs to
|
||||
verify that this is Grant's cloud Recaps server (vs. someone else trying
|
||||
to forge user-id headers). The per-user identity comes from the explicit
|
||||
headers.
|
||||
|
||||
---
|
||||
|
||||
## Cloud Recaps is paid-only
|
||||
|
||||
**Decided:** cloud Recaps drops the "free signed-in" tier entirely. Self-
|
||||
hosted IS the free path.
|
||||
|
||||
User states on cloud:
|
||||
- **Anon trial** — cookie-tracked, no account. Gets a small allowance of
|
||||
credits to taste-test. After the trial runs out, must subscribe to
|
||||
continue using the cloud service.
|
||||
- **Subscribed** — account exists, `users.tier ∈ {pro, max}`,
|
||||
`subscription_expires_at` in the future.
|
||||
- **Expired subscription** — account preserves the library, can't
|
||||
summarize new things until renewed. Renewal anytime restores access.
|
||||
|
||||
What this means for the codebase: `tenant_credits` table's "free signed-in"
|
||||
codepath stops applying to cloud users. The table stays in the codebase
|
||||
because self-hosted multi-tenant operators (Alice running Recaps for her
|
||||
family) still need per-tenant accounting locally.
|
||||
|
||||
---
|
||||
|
||||
## Payment provider strategy
|
||||
|
||||
**Pro/Max purchase page** has two paths:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Upgrade to Pro — $X/month │
|
||||
│ │
|
||||
│ • 50 Recap credits per month │
|
||||
│ • Channel + podcast subscriptions │
|
||||
│ • Auto-queue + priority processing │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ ⚡ Pay with Bitcoin │ │ ← primary, inline BTCPay
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Pay with card → │ ← link → Zaprite hosted
|
||||
│ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Bitcoin path: monthly upfront, manual renewal
|
||||
|
||||
- User pays one BTCPay invoice for 30 days of Pro
|
||||
- Inline Lightning QR + BOLT11 + copy button (same UX as credit packs)
|
||||
- On settle, Recaps sets `subscription_expires_at = now + 30 days`
|
||||
- No card on file, no autorenewal — Lightning doesn't have a clean
|
||||
recurring-billing primitive
|
||||
|
||||
### Renewal-link emails (NEW, needed for Bitcoin path)
|
||||
|
||||
Recaps sends automated emails near expiry:
|
||||
- **7 days before expiry** — "Your Pro sub renews in 7 days. Tap to renew →"
|
||||
- **Day of expiry** — "Your Pro sub expired. Tap to renew →"
|
||||
- **7 days after expiry** — "We've paused your Pro features. Renew anytime →"
|
||||
- After 30 days post-expiry — stop emailing (avoid being a nag)
|
||||
|
||||
Mechanism:
|
||||
- Email contains a tokenized URL: `https://recaps.cc/renew?token=<base64>`
|
||||
- Token encodes `{ user_id, action: "renew_pro" }`, short-lived (~14 days)
|
||||
- Click → Recaps mints fresh BTCPay invoice, renders inline Lightning UI
|
||||
- On settle, `subscription_expires_at += 30 days` (extends from whichever
|
||||
is later: existing expiry, or current time — so a user who renews 5
|
||||
days early doesn't lose those days)
|
||||
|
||||
Card subscribers don't need this — Zaprite handles recurring billing
|
||||
natively via Stripe.
|
||||
|
||||
### Card path: Zaprite handles everything
|
||||
|
||||
- Click "Pay with card" → redirect to Zaprite hosted checkout
|
||||
- Zaprite collects card info, charges monthly, handles retries on failure
|
||||
- Zaprite webhook fires → Recaps updates `subscription_expires_at`
|
||||
- Cancel button in Settings → Plan calls Zaprite cancel API
|
||||
- **Card premium pricing handled inside Zaprite admin** (not in Recaps
|
||||
code) — Grant configures the card-monthly price to be N% higher than
|
||||
Bitcoin-monthly in Zaprite's product settings
|
||||
|
||||
---
|
||||
|
||||
## Self-hosted scenarios
|
||||
|
||||
**Self-hosted Recaps is free + open source.** Run the `.s9pk` anywhere,
|
||||
no license check to run the software.
|
||||
|
||||
| Scenario | Subscriber to Grant's relay? | How relay access works |
|
||||
|---|---|---|
|
||||
| Bob runs Recaps for himself, brings his own Gemini key | No | Bob pays Google directly. Default `.s9pk` has no relay configured — Bob enters his AI provider keys in Settings. |
|
||||
| Bob runs Recaps for himself + wants Grant's relay | Yes | Bob has a cloud Pro subscription. He clicks "Take Recaps home" in cloud settings → Recaps mints a fresh LIC1 token via Keysat with `expires_at = subscription_expires_at`. Bob pastes it into his self-hosted install. Self-hosted Recaps uses the token as `Authorization: Bearer LIC1-...` to the relay. |
|
||||
| Alice runs Recaps for family, brings own Gemini key | No | Alice's family is invisible to Grant. |
|
||||
| Alice runs Recaps for family + uses Grant's relay | Yes | Alice has a cloud subscription. Family-tenants on Alice's install all share Alice's relay-credit pool via Alice's pasted license. Alice manages per-family-member accounting locally via the existing `tenant_credits` + operator-grant flow. If Alice needs more credit headroom for her family, she upgrades her cloud sub to Max or buys credit packs. |
|
||||
|
||||
### What the license is for, after the simplification
|
||||
|
||||
**One job: the credential that authenticates a self-hosted Recaps install
|
||||
against Grant's relay.**
|
||||
|
||||
Not "are you Pro on cloud" — Recaps DB knows that.
|
||||
Not "credit pool key" — `user:<recaps_user_id>` keys cloud requests, `lic:<fp>` keys self-hosted.
|
||||
Not "subscription expiry" — `users.subscription_expires_at` in Recaps DB is the source of truth.
|
||||
|
||||
The license is a **deliverable artifact**, minted on demand only when a
|
||||
cloud Pro user explicitly clicks "Take Recaps home." Most cloud users
|
||||
never click it; they don't need a license. The license's `expires_at`
|
||||
mirrors the user's subscription expiry, so when their cloud sub lapses,
|
||||
the self-hosted install's relay access stops working naturally.
|
||||
|
||||
### Grace period for self-hosted licenses
|
||||
|
||||
**Decided:** Keysat handles this. When a cloud subscription lapses, Keysat
|
||||
can keep the license valid for a short grace period (e.g., 7 days) before
|
||||
revoking. Recaps doesn't manage this — it's a Keysat-internal policy.
|
||||
|
||||
---
|
||||
|
||||
## Migration order
|
||||
|
||||
When ready to implement:
|
||||
|
||||
1. **Decide self-hosted = free + open source** ← already decided. Removes
|
||||
the "is this install licensed" check from the cloud path; keeps it
|
||||
only as a guard on relay access.
|
||||
2. **Recaps schema additions:** `users.tier`, `users.subscription_expires_at`,
|
||||
`users.zaprite_customer_id`, `users.zaprite_subscription_id`,
|
||||
`users.bitcoin_renewal_token_hash` (single-use). Migrate existing users
|
||||
by deriving tier + expires_at from their attached license at boot time.
|
||||
3. **Zaprite webhook handler:** Recaps endpoint accepting Zaprite's order
|
||||
+ subscription lifecycle events; updates user row accordingly.
|
||||
4. **Relay header migration:** Cloud Recaps sends `X-Recap-User-Id` +
|
||||
`X-Recap-User-Tier`. Relay accepts BOTH old (license-keyed) and new
|
||||
(user-id-keyed) headers for one release as a compat window.
|
||||
5. **Drop license attachment from cloud signup:** Pro/Max purchase via
|
||||
Zaprite or BTCPay now updates `users.tier` + `users.subscription_expires_at`
|
||||
directly. No LIC1 token attached at signup.
|
||||
6. **Renewal-email pipeline:** Scheduled job in Recaps that scans for
|
||||
subscriptions approaching expiry, sends the renewal email with a
|
||||
one-time-use renewal token. Token consumption flow on `/renew?token=…`.
|
||||
7. **Pro/Max purchase UX redesign:** Bitcoin primary + Card secondary
|
||||
layout. Bitcoin path uses existing inline BTCPay flow. Card path
|
||||
redirects to Zaprite hosted checkout.
|
||||
8. **"Take Recaps home" rework:** Becomes an explicit user-initiated
|
||||
action that mints a fresh LIC1 token via Keysat at click time, not at
|
||||
signup. UI shows the token with copy-to-clipboard + install
|
||||
instructions.
|
||||
9. **Cancel-subscription button:** Settings → Plan → cancel. Hits Zaprite
|
||||
cancel API for card subs; for Bitcoin subs there's nothing to cancel
|
||||
(just let it lapse — they paid one month, they get one month).
|
||||
10. **Remove "free signed-in" path from cloud:** Anon trial credits stay
|
||||
as taste-test; signup grant goes away. Self-hosted is the free path
|
||||
going forward.
|
||||
|
||||
Estimated effort: ~1 week of focused work end-to-end. Steps 2–5 are the
|
||||
core; the rest are polish on top.
|
||||
|
||||
---
|
||||
|
||||
## Decided open questions
|
||||
|
||||
| Q | Decision |
|
||||
|---|---|
|
||||
| Annual or monthly Bitcoin subscription? | **Monthly upfront, manual renewal** with automated renewal-link emails |
|
||||
| Same price across paths, or premium/discount? | **Card premium configured inside Zaprite admin**, not in Recaps code |
|
||||
| Grace period on self-hosted licenses when cloud sub lapses? | **Keysat handles this** — Recaps doesn't manage |
|
||||
| Free tier on cloud? | **No — cloud is paid-only**, self-hosted is the free path |
|
||||
|
||||
---
|
||||
|
||||
## What stays (don't break)
|
||||
|
||||
- Relay's credit ledger + tier-quota math
|
||||
- BTCPay webhook → relay-pool crediting (for one-shot Lightning credit packs)
|
||||
- Inline Lightning UX for credit packs (already shipped, working)
|
||||
- Anon trial mechanic (cookie-tracked taste-test)
|
||||
- The "Take Recaps home" feature itself (just changes when the token is minted)
|
||||
- The Keysat license-issuing pipeline (just changes who calls it and when)
|
||||
|
||||
## What changes
|
||||
|
||||
- Recaps stops attaching a license at every Pro/Max signup
|
||||
- Recaps gains its own subscription state (`users.tier` + `expires_at`)
|
||||
- Relay accepts `X-Recap-User-Id` for cloud requests (compat with old license-keyed for self-hosted)
|
||||
- Pro/Max purchase UX gets the two-option layout (Bitcoin primary, Card link)
|
||||
- New renewal-email pipeline for Bitcoin subscribers
|
||||
- Cancel-subscription button wired to Zaprite
|
||||
- Cloud loses the "free signed-in" tier; self-hosted IS the free path
|
||||
- "Take Recaps home" reshapes as on-demand mint, surfaced as an explicit
|
||||
button in cloud settings
|
||||
|
||||
## What's deleted
|
||||
|
||||
- Nothing — only deprecated. Old license attachment code stays as a fallback for one release window. After migration is fully verified, can be cleaned up in a follow-up.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,282 @@
|
||||
# Path 2B + Path 1 Interweave Plan
|
||||
|
||||
Companion doc to `architecture-simplification-plan.md` (Path 1) and the
|
||||
chat thread that proposed Path 2A (relay-only upload, ship first).
|
||||
|
||||
This doc covers:
|
||||
|
||||
1. **Path 2B** — bringing internal-meeting analysis into the Recaps
|
||||
cloud frontend as a first-class feature alongside YouTube/podcast
|
||||
summaries.
|
||||
2. **How Path 2B depends on Path 1** — what Path 1 unlocks vs. what
|
||||
could be partially built without it.
|
||||
3. **Migration path** — how Path 2A's relay-only upload data flows
|
||||
forward into Path 2B's cloud-side library.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Recap Relay (the operator-side backend) is now generic enough to
|
||||
analyze ANY audio — not just YouTube/podcasts. The download step is
|
||||
the only YouTube-specific code; everything downstream (transcribe →
|
||||
diarize → cluster → analyze → polish) applies cleanly to arbitrary
|
||||
audio. Path 2A exposes this via a relay-admin-only upload UI so
|
||||
operator Grant can run internal meeting analysis on his own hardware
|
||||
TODAY without waiting on Recaps multi-tenant work.
|
||||
|
||||
Path 2B is the longer arc: same capability surfaced in the cloud
|
||||
Recaps app, so signed-in users can submit private meeting audio,
|
||||
manage it alongside their other content, and (optionally) share with
|
||||
colleagues.
|
||||
|
||||
---
|
||||
|
||||
## Path 1 (recap) state
|
||||
|
||||
`architecture-simplification-plan.md` defines:
|
||||
|
||||
- One Recaps binary, two modes via `RECAP_MODE=single|multi` env var
|
||||
- Magic-link + optional password auth via StartOS SMTP
|
||||
- Per-user library at `/data/history/<userId>/<sessionId>.json`
|
||||
- Per-user keysat license (mintable via Keysat admin API)
|
||||
- BTCPay subscription + one-time credit-purchase flows
|
||||
- Lite-settings UI for non-operator cloud users
|
||||
- Self-hosted operator stays single-tenant by default; the .s9pk
|
||||
ships free + open
|
||||
|
||||
Status: written but not built. The relay-side work has continued in
|
||||
parallel (FIFO queue, clustering suppression, polish pass) which
|
||||
strengthens the case for Path 1 — the cloud user experience benefits
|
||||
from all of it, and the keysat-license layer is increasingly
|
||||
friction-without-value for cloud users who already have email-verified
|
||||
accounts.
|
||||
|
||||
---
|
||||
|
||||
## Path 2B — internal meetings in cloud Recaps
|
||||
|
||||
### What it looks like
|
||||
|
||||
A signed-in Recaps user gets a second submission affordance alongside
|
||||
the existing "Paste a YouTube/podcast link" input:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Submit content │
|
||||
│ ○ Paste a YouTube/podcast link │
|
||||
│ ○ Upload audio file (private) [Choose file…] │
|
||||
│ │
|
||||
│ Title: [_____________________________] │
|
||||
│ Participants: [_____________________________] (opt) │
|
||||
│ Meeting type: [▼ default / 1:1 / all-hands / …] │
|
||||
│ │
|
||||
│ [ Summarize ] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The submission flows the same way YouTube submissions do:
|
||||
Recaps-app → Relay (`/relay/v1/summarize-upload` for files,
|
||||
existing `/relay/v1/summarize-url` for URLs). The relay handles both
|
||||
via the same pipeline — only the input step differs (download vs.
|
||||
multipart receive).
|
||||
|
||||
### Library + rendering
|
||||
|
||||
Each saved session in the cloud user's library has a `type` field:
|
||||
|
||||
- `"youtube"` — existing rendering (video player + topics + transcript chips)
|
||||
- `"podcast"` — existing podcast rendering (audio player + topics + transcript)
|
||||
- `"meeting"` — new rendering: no media player; topics + transcript chips
|
||||
expandable below each topic card; PLUS a "Meeting analysis" block at
|
||||
the top with Decisions / Action Items / Open Questions / Key Quotes
|
||||
(the structured-extras pass from Phase 2 of Path 2A)
|
||||
|
||||
Library list view shows a small icon distinguishing meeting items so
|
||||
the user can filter at a glance.
|
||||
|
||||
### Privacy + sharing
|
||||
|
||||
Meetings are PRIVATE by default — visible only to the submitting user.
|
||||
Two later features:
|
||||
|
||||
- **Share with team** — a meeting can be optionally shared with N
|
||||
named cloud-Recaps users (each looks up by email). Other users see
|
||||
it in a "Shared with me" library section.
|
||||
- **Export to markdown / PDF** — already exists for YouTube content;
|
||||
add the meeting-extras blocks to the export.
|
||||
|
||||
### Audio handling
|
||||
|
||||
- Uploaded audio goes from browser → Recaps-app (Node Express,
|
||||
multipart middleware) → Recap Relay (forward as multipart to
|
||||
`/relay/v1/summarize-upload`).
|
||||
- Recaps-app's tmp file is deleted immediately after the relay
|
||||
acknowledges receipt.
|
||||
- Relay's tmp file is deleted after the pipeline completes.
|
||||
- NEITHER side keeps the audio. The TRANSCRIPT + analysis are saved.
|
||||
If the user wants to re-process (different prompt set), they'd
|
||||
re-upload the audio.
|
||||
- The transcript stays in the user's per-user library at
|
||||
`/data/history/<userId>/<sessionId>.json`, scoped + isolated.
|
||||
|
||||
---
|
||||
|
||||
## Path 2B's hard prerequisite: Path 1
|
||||
|
||||
Path 2B fundamentally requires multi-tenant auth in Recaps. Without
|
||||
Path 1:
|
||||
|
||||
- No per-user library separation
|
||||
- No way to know whether the meeting audio is private to a user vs.
|
||||
visible to everyone running this Recaps instance
|
||||
- No way to share with named other users
|
||||
- No way to bill upload-heavy users differently from URL-only users
|
||||
(uploads might warrant a different price tier given the storage
|
||||
cost)
|
||||
|
||||
You COULD build a stripped-down Path 2B on single-tenant Recaps —
|
||||
operator uploads audio, it's saved to the operator's library, no
|
||||
sharing. But that's roughly equivalent to Path 2A with a fancier UI,
|
||||
just on the wrong side of the codebase split (Recaps-app vs. relay).
|
||||
Not worth the duplication.
|
||||
|
||||
So: **ship Path 2A as the immediate beachhead, do Path 1 next, then
|
||||
Path 2B on top.**
|
||||
|
||||
### What Path 1 unlocks (relevant to 2B)
|
||||
|
||||
The Path 1 doc already covers most of what 2B needs:
|
||||
- Per-user library at `/data/history/<userId>/*.json`
|
||||
- Auth-aware request scoping (`req.userId`)
|
||||
- Per-user keysat license OR the simplified user-id + tier headers
|
||||
- BTCPay subscription tracking (for billing upload-heavy use)
|
||||
|
||||
Path 1's "Lite-settings panel for cloud users" already imagines a
|
||||
post-auth Recaps UI without operator config noise. The submission
|
||||
input would extend in 2B to add the upload option.
|
||||
|
||||
The relay-side header migration Path 1 proposes (`X-Recap-User-Id` +
|
||||
`X-Recap-User-Tier` replacing the bearer license token) is also
|
||||
beneficial for 2B — uploads from a single user with N concurrent
|
||||
browsers all carry the same user-id, so credit accounting is per-user
|
||||
not per-install.
|
||||
|
||||
---
|
||||
|
||||
## How Path 2A's data flows into Path 2B
|
||||
|
||||
When Path 2A ships (relay-only upload, results saved to
|
||||
`/data/internal-meetings/<id>.json` on the relay), those summaries
|
||||
are tied to the OPERATOR (Grant) — there's no cloud-user concept yet.
|
||||
|
||||
When Path 2B lands:
|
||||
|
||||
1. **Migration script** — walks `/data/internal-meetings/*.json` and
|
||||
re-homes each entry under the operator's cloud user account
|
||||
(`/data/history/owner/*.json` initially, then `/data/history/
|
||||
<operator-user-id>/*.json` after Path 1's owner→admin rename).
|
||||
2. **Same JSON shape** — Path 2A should save in a shape compatible
|
||||
with Path 2B's expected library shape (chunks + entries + speakers
|
||||
+ meeting-extras). One way to guarantee this: design the Phase 1
|
||||
save shape now to match what Recaps' `saveToHistory` produces for
|
||||
`type=meeting`, even though no Recaps UI consumes it yet.
|
||||
3. **No re-processing needed** — transcripts and analysis transfer
|
||||
verbatim. The user just sees them appear in their cloud library.
|
||||
|
||||
The relay-side upload endpoint (`/relay/v1/summarize-upload`) is the
|
||||
same in both worlds. Path 2A calls it via the operator dashboard's
|
||||
admin auth; Path 2B calls it via Recaps-app server proxying a
|
||||
signed-in cloud user's POST.
|
||||
|
||||
So the relay code path is built once and serves both.
|
||||
|
||||
---
|
||||
|
||||
## Operator-editable prompt sets (Phase 3 in 2A; carries to 2B)
|
||||
|
||||
The "meeting type" dropdown is operator-editable. Each set has:
|
||||
- Name (e.g. "1:1 with direct report")
|
||||
- Topic-analysis prompt template (replaces the YouTube/podcast version)
|
||||
- Meeting-extras prompt template (Decisions / Action Items / ...)
|
||||
- Optional metadata schema overrides (e.g. "this meeting type always
|
||||
expects 2 participants")
|
||||
|
||||
Stored in `relay_meeting_prompt_sets_json` config field. Operator
|
||||
edits via the dashboard (similar to existing prompts panel). Cloud
|
||||
Recaps users pick a set at submission time; the relay applies it
|
||||
during analyze + polish.
|
||||
|
||||
Default sets ship built-in:
|
||||
- `default` — neutral meeting prompt, all sections enabled
|
||||
- `1on1` — emphasizes Action Items + Open Questions; light on Decisions
|
||||
- `all-hands` — emphasizes Decisions + Key Quotes; less actionable
|
||||
- `customer-interview` — emphasizes Key Quotes + Open Questions; light
|
||||
on Decisions
|
||||
- `standup` — short-form; Action Items + Open Questions only
|
||||
|
||||
---
|
||||
|
||||
## Suggested order of operations
|
||||
|
||||
1. **Path 2A Phase 1** — relay-only upload, no extras, basic
|
||||
topic+transcript rendering. ~2-3 days.
|
||||
2. **Path 2A Phase 2** — meeting-extras analysis pass (Decisions /
|
||||
Action Items / Open Questions / Key Quotes). ~1-2 days.
|
||||
3. **Path 2A Phase 3** — prompt sets dropdown. ~1 day.
|
||||
4. (Use Path 2A in production for some weeks; gather feedback on
|
||||
prompts, output quality, UX.)
|
||||
5. **Path 1** — multi-tenant Recaps. ~3-4 weeks per the existing
|
||||
architecture-simplification doc, modulo amendments.
|
||||
6. **Path 2B** — surface internal meetings in cloud Recaps. ~1.5-2
|
||||
weeks given Path 1 has shipped. Migrates the Path 2A artifacts
|
||||
into the new per-user library.
|
||||
|
||||
Total wall time: ~6-8 weeks for the full arc. Path 2A capability
|
||||
available to operator after step 1 (~2-3 days). Cloud users get
|
||||
meetings after step 6.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
These should be settled BEFORE Path 2B build starts:
|
||||
|
||||
1. **Pricing for uploads.** Does an upload count the same as a URL
|
||||
submission against a user's monthly credit cap? Or is it priced
|
||||
differently to reflect the upload bandwidth + storage cost? My
|
||||
default: same price (1 credit per submission) — bandwidth cost is
|
||||
trivial, storage is just JSON.
|
||||
|
||||
2. **Audio retention.** Default: never retain. Optional per-user
|
||||
setting "keep audio for 7/30/90 days so I can re-process with a
|
||||
different prompt set"? Adds operator storage cost; only worth it
|
||||
if users actually want it.
|
||||
|
||||
3. **Sharing model.** Path 2B Phase 1 = no sharing, private only.
|
||||
Phase 2 = shared with N named users. Phase 3 = optional public
|
||||
share URL. Each phase adds auth/permission complexity. Worth
|
||||
designing the data model now (`{ ownerId, sharedWith: [userId] }`)
|
||||
even if only ownerId is populated in Phase 1.
|
||||
|
||||
4. **Speaker name persistence.** If a user identifies "Speaker_A" as
|
||||
"Matt Hill" in one meeting, should that name auto-suggest in the
|
||||
next meeting if a fingerprint match is found? Requires storing
|
||||
fingerprints per-user across meetings. Big privacy + product
|
||||
decision. My instinct: opt-in toggle in user settings, default
|
||||
off.
|
||||
|
||||
5. **Meeting type defaults.** Should Recaps' submission flow have a
|
||||
default meeting type, or force the user to pick? My instinct:
|
||||
default to "default" set; let users pick if they want.
|
||||
|
||||
---
|
||||
|
||||
## Decision points for Grant
|
||||
|
||||
- Confirm the phasing above (2A → 1 → 2B) is the right order
|
||||
- Pre-commit to the "uploaded audio is never retained" default — it's
|
||||
the privacy-safer choice and aligns with how YouTube downloads
|
||||
already work
|
||||
- Pick a side on the open questions above before Path 2B starts, OR
|
||||
defer them as Phase 2/3 of Path 2B
|
||||
@@ -0,0 +1,131 @@
|
||||
# Per-Tenant Subscriptions — Implementation Plan
|
||||
|
||||
**Status:** ✅ **ALL STEPS DONE (app 0.2.149, 2026-06-04).** Per-tenant
|
||||
subscriptions are live: every Pro/Max user gets their own subscriptions +
|
||||
auto-queue, processed under their own account. The gate is flipped to
|
||||
tier-based. Offline-verified (99 server tests incl. the ephemeral-session
|
||||
mechanism + both-mode boot smokes); the actual processing-as-owner run is
|
||||
the on-device test (see checklist below).
|
||||
|
||||
**Step 4 (the hard part), as built:** the background processor now finds
|
||||
approved items across every scope (`listAutoQueueScopes`) and processes each
|
||||
AS its owner — `processItemInternally(item, scope)` mints a short-lived real
|
||||
session (`mintInternalSession` in auth-routes.js) for the owning user, sends
|
||||
it as the `recap_session` cookie on the loopback `/api/process` call, and
|
||||
deletes it on every exit path. No auth bypass — a bad/expired token just
|
||||
401s and the item is marked failed. Single mode sends no cookie (resolves to
|
||||
"owner"). `userIdForScope`: single→null; multi "owner"→admin; tenant→the
|
||||
user id. Bonus: this also fixes the operator's OWN multi-mode auto-processing,
|
||||
which previously ran the loopback with no identity.
|
||||
|
||||
**Gate flip:** `PRO_FEATURE_GATES` subscriptions gate is now tier-based in
|
||||
multi mode (Pro/Max/admin pass, free → 402); frontend `canUseSubscriptions()
|
||||
= hasEntitlement("subscriptions")` (operator-only clauses reverted).
|
||||
|
||||
## Landed in 0.2.147
|
||||
- `server/subscriptions.js` — scope-keyed storage for subscriptions / skip /
|
||||
seen / auto-queue + file-locked `mutateAutoQueue` (atomic read-modify-write,
|
||||
replacing the global in-memory `autoQueue`), `listSubscriptionScopes()`,
|
||||
`migrateGlobalSubscriptionsToOwner()`, plus the dedup (`getProcessedVideoIds`,
|
||||
`isKnownVideo`).
|
||||
- `index.js` — the check loop fans out over `listSubscriptionScopes()` into a
|
||||
per-scope `checkScopeSubscriptions(scope)`; every endpoint resolves
|
||||
`scope = subScope(req)` (= scopeForRequest, "owner" for the operator); the
|
||||
processor + boot recovery use `mutateAutoQueue`; boot runs the migration +
|
||||
per-scope library reconcile. Behind the gate every scope resolves to
|
||||
"owner", so behaviour is unchanged for the operator — the plumbing is just
|
||||
per-scope now.
|
||||
- `history.js` — `addToSkipList(scope, videoId)` is scope-keyed.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Each signed-in Pro/Max tenant manages their **own** channel/podcast
|
||||
subscriptions; discovered episodes land in **their** auto-queue; approving
|
||||
one summarizes it under **their** account (their credits, their library,
|
||||
their relay identity). The operator (admin) keeps theirs. No tenant sees or
|
||||
affects another's.
|
||||
|
||||
## What already exists (foundation)
|
||||
|
||||
- `server/subscriptions.js` — extracted, unit-tested. `getProcessedVideoIds(scope)`
|
||||
(scope-aware library scan — the dedup fix) + `isKnownVideo()` (pure
|
||||
predicate). Already takes a `scope`, so per-tenant dedup is free.
|
||||
- `history.js` — `scopeForRequest(req)` (→ "owner" for admin/single, else
|
||||
`safeComponent(user.id)`), `getScopeHistoryDir(scope)`, `scopeDir`,
|
||||
`renameScopeDir`. The whole per-scope filesystem layer is in place.
|
||||
- The interim isolation gate: `PRO_FEATURE_GATES[subscriptions].adminOnlyInMulti`
|
||||
(server) + `canUseSubscriptions()` (frontend). Both get relaxed here.
|
||||
|
||||
## The work
|
||||
|
||||
### 1. Scope the storage (mechanical, testable)
|
||||
Move the four global files into `subscriptions.js`, each keyed by scope and
|
||||
rooted at `scopeDir(scope)` instead of the history root:
|
||||
`subscriptions.json`, `auto-queue.json`, `skip-list.json`, `seen-list.json`.
|
||||
Add `loadSubscriptions(scope)` / `saveSubscriptions(scope, …)` + the skip /
|
||||
seen / auto-queue equivalents. **Drop the global in-memory `autoQueue`** —
|
||||
load/save per scope per request (the in-memory cache is what makes the
|
||||
current code single-tenant). Unit-test the round-trips per scope.
|
||||
|
||||
### 2. Rescope the endpoints (mechanical)
|
||||
All ~15 `/api/subscriptions*` + `/api/auto-queue*` handlers derive
|
||||
`scope = scopeForRequest(req)` and read/write that scope's files. Relax the
|
||||
gate: drop `adminOnlyInMulti`; gate on the `subscriptions` entitlement
|
||||
(tier) instead, so paid tenants get in. Update `canUseSubscriptions()` to
|
||||
`hasEntitlement("subscriptions")` (drop the `!isMulti||isAdmin` clause) and
|
||||
revert the operator-only frontend branches.
|
||||
|
||||
### 3. Rescope the check loop (moderate)
|
||||
`_checkSubscriptionsInner()` becomes per-scope. Enumerate scopes that have a
|
||||
subscriptions file (readdir `history/`, keep subdirs whose `subscriptions.json`
|
||||
is non-empty; plus "owner"). For each: load that scope's subs, dedup via
|
||||
`getProcessedVideoIds(scope)` + the scope's skip/seen/queue, append to that
|
||||
scope's auto-queue. The boot + hourly timers iterate all scopes.
|
||||
|
||||
### 4. Fix the processor identity (the risky bit — needs on-device test)
|
||||
`backgroundProcessor()` + `processItemInternally(item)` must process each
|
||||
item **as its owner**. The item already can carry `scope`/`ownerUserId`.
|
||||
|
||||
**Recommended: ephemeral session.** Before the loopback request, mint a
|
||||
short-lived row in the existing `sessions` table for the owner, send its
|
||||
token as the `Cookie`, then delete the row in a `finally`. tenant-auth then
|
||||
resolves `req.user` = owner normally → correct scope, credits, relay
|
||||
identity. Reuses real auth (no new trust path). **Failure mode is safe:** a
|
||||
bad/missing session just makes the internal request 401 → the item is
|
||||
marked `failed`, never an auth hole. Single mode unchanged (no auth, scope
|
||||
already "owner").
|
||||
|
||||
*Alternative:* extract the core of the `/api/process` handler into a
|
||||
function callable in-process with an explicit `{scope, identity}` (no HTTP,
|
||||
no cookie). Cleaner long-term but a large refactor of a big handler — more
|
||||
risk of behavioral drift. Prefer the ephemeral session first.
|
||||
|
||||
The single global processor loop stays fine — it just pulls approved items
|
||||
across all scopes (each item knows its owner) and processes sequentially
|
||||
with the existing inter-item delay.
|
||||
|
||||
### 5. Migration (one-time, on boot)
|
||||
Move the existing history-root files
|
||||
(`subscriptions.json`/`auto-queue.json`/`skip-list.json`/`seen-list.json`)
|
||||
into `history/owner/` (the admin's scope) once, if present and the target
|
||||
doesn't exist. Preserves the operator's current subscriptions under their
|
||||
own scope. Idempotent.
|
||||
|
||||
## On-device test checklist (gates "done")
|
||||
1. Tenant A subscribes to a channel; tenant B sees **nothing** of A's.
|
||||
2. A's discovered episode appears only in A's queue; approving it
|
||||
summarizes under **A** (A's credits decremented, saved to A's library,
|
||||
A's relay `user:<id>` pool billed).
|
||||
3. Operator's own subscriptions still discover + auto-process.
|
||||
4. Per-scope dedup: a video already in A's library is not re-queued for A;
|
||||
the same video can still be independently queued for B.
|
||||
5. Crash-recovery: items stuck `processing` resume under the right owner.
|
||||
6. Single mode unaffected.
|
||||
|
||||
## Effort / risk
|
||||
Steps 1–3 + 5: ~half a day, low risk, unit-testable offline. Step 4: the
|
||||
real work — small code, but auth/billing-adjacent, so it carries the
|
||||
testing burden. Land 1–3 behind the existing operator-only gate first
|
||||
(no behavior change for tenants), verify, then flip the gate + ship 4.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Self-Serve Pro/Max Purchase — Implementation Plan
|
||||
|
||||
**Status:** Phases 1–4 BUILT + INSTALLED + LIVE on immense-voyage.local
|
||||
(relay 0.2.121, app 0.2.153) as of 2026-06-08. Bitcoin rail end-to-end test
|
||||
pending; card rail awaits operator Zaprite config. Phase 5 (SMTP expiry
|
||||
reminders) remains. Follows the core decoupling (the relay owns tiers,
|
||||
keyed by user-id) — this lets users buy their own Pro/Max instead of the
|
||||
operator granting them by hand.
|
||||
|
||||
**Phase 4 (cards via Zaprite) — DONE.** Symmetric with the Bitcoin rail
|
||||
(one-time prepaid checkout, NOT Zaprite recurring): relay `zaprite-client.js`
|
||||
(POST /v1/orders + GET /v1/orders/:id), `POST /relay/tier-zaprite-order`
|
||||
(operator-keyed), `POST /relay/zaprite/webhook` (re-fetch-to-verify — no
|
||||
signature needed; both rails land at extendUserTier). Config:
|
||||
`relay_zaprite_{api_key,base_url,currency}` + `relay_tier_prices_fiat_cents_json`
|
||||
(default $21/$42), set via the new "Set Zaprite Connection" StartOS action.
|
||||
`/api/billing/buy` gained `method:"card"`; tier-plans reports `card_available`
|
||||
+ fiat prices; UI shows "Pay by card · $21" only when Zaprite is configured.
|
||||
Operator TODO: run "Set Zaprite Connection" (paste API key) + register the
|
||||
webhook at `https://<relay-host>/relay/zaprite/webhook` in Zaprite.
|
||||
|
||||
## Decisions (from Grant)
|
||||
|
||||
- **Prepaid periods, NOT auto-recurring.** A user pays for a fixed period
|
||||
(default **30 days**) of Pro/Max. Near expiry they get an email reminder
|
||||
and pay again to extend. At expiry, the tier drops to Core. No stored
|
||||
payment method, no card-vault / dunning.
|
||||
- **Two payment rails:**
|
||||
- **Bitcoin / Lightning via BTCPay** — the *preferred* path. Relay already
|
||||
has `btcpay-client.js` (createInvoice + webhook HMAC) used for credit
|
||||
purchases; extend it for tier purchases.
|
||||
- **Cards via Zaprite** — secondary. Grant has a Zaprite org + API +
|
||||
webhooks. New integration.
|
||||
- **UI:** the main pill button is **"Pay with Bitcoin"** (opens a BTCPay
|
||||
invoice). Directly below it, a smaller **"Pay by card"** link (opens a
|
||||
Zaprite checkout).
|
||||
- **Expiry reminders via email.** Set up SMTP (Amazon SES per the Start9
|
||||
recommendation) and send an automated "your Pro/Max expires in N days"
|
||||
email. Recaps already has an SMTP transport (magic-link emails).
|
||||
- **Prices:** from the relay's `relay_tier_prices_usd_json` (today Pro $5 /
|
||||
Max $15 per period), USD-denominated, paid in sats for BTCPay.
|
||||
|
||||
## Phases (each shippable on its own)
|
||||
|
||||
### Phase 1 — Relay: prepaid tier model + expiry enforcement (foundation)
|
||||
Rail-agnostic. Everything else depends on it.
|
||||
- `setUserTier({userId, tier, periodDays})` → set tier, set
|
||||
`subscription_expires_at`. **Extend from the current expiry** if the user
|
||||
is already active (so paying early adds time, doesn't reset it).
|
||||
- **Enforce expiry:** when the relay resolves a user's tier (identityTier /
|
||||
the metered-route gate), treat `subscription_expires_at < now` as Core.
|
||||
Add a lazy check + a periodic sweep so expired users actually drop.
|
||||
- Keep the operator-grant path (`/relay/user-tier`) working — it's the comp
|
||||
tool. A manual grant can set no-expiry (operator comp) vs a purchase sets
|
||||
a dated period.
|
||||
|
||||
### Phase 2 — Bitcoin/Lightning purchase (BTCPay)
|
||||
- Relay: `POST /relay/tier-invoice` (operator-key authed) — body
|
||||
`{user_id, tier, period_days}` → `createInvoice` with metadata
|
||||
`{kind:"tier_subscription", userId, tier, periodDays}` → returns the
|
||||
checkout URL + invoice id.
|
||||
- Relay webhook: on a settled `tier_subscription` invoice → `setUserTier`
|
||||
(extend by periodDays). (Mirrors the existing credit-purchase webhook
|
||||
branch.)
|
||||
- Recaps: a `pending_purchases`-style record + the settle→poll→cache-tier
|
||||
loop (reuse the credit-purchase machinery). On settle, refresh
|
||||
`/api/license-status` so the badge flips to Pro/Max.
|
||||
|
||||
### Phase 3 — Purchase UI
|
||||
- Tier picker (Pro / Max, price, "30 days") with the **"Pay with Bitcoin"**
|
||||
pill + **"Pay by card"** link. Reuse / replace the existing buy modal.
|
||||
- Bitcoin → opens the BTCPay checkout (Phase 2). Card → opens Zaprite
|
||||
(Phase 4).
|
||||
|
||||
### Phase 4 — Cards via Zaprite
|
||||
- Relay (or Recaps): create a Zaprite checkout for the tier (Grant's org +
|
||||
API), metadata carrying `{userId, tier, periodDays}`.
|
||||
- Zaprite webhook → verify signature → `setUserTier` (extend). Same landing
|
||||
point as the BTCPay webhook.
|
||||
- Wire the "Pay by card" link to it.
|
||||
|
||||
### Phase 5 — Expiry reminder emails
|
||||
- SMTP via SES (Grant sets up SES + StartOS System SMTP; Recaps' transport
|
||||
already exists).
|
||||
- Periodic job: find users with `subscription_expires_at` in ~N days, email
|
||||
a "renew" notice with a link back to the purchase UI. Idempotent (don't
|
||||
double-send).
|
||||
|
||||
## Notes / open defaults (sensible unless Grant says otherwise)
|
||||
- Period = 30 days. Grace = none beyond the advance email (downgrade on
|
||||
expiry). Extend-from-current-expiry on early renewal.
|
||||
- Relay stays **`make install` only** (private — never registry-deploy).
|
||||
- The operator-key path authenticates Recaps→relay for invoice creation, the
|
||||
same as the tier-grant flow.
|
||||
Reference in New Issue
Block a user