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:
+12
@@ -26,6 +26,18 @@ WORKDIR /app/server
|
||||
COPY server/package.json server/package-lock.json* ./
|
||||
RUN npm ci --omit=dev --ignore-scripts 2>/dev/null || npm install --omit=dev --ignore-scripts
|
||||
|
||||
# better-sqlite3 is a native (C++) module — `--ignore-scripts` above
|
||||
# skips the postinstall hook that fetches its prebuilt binary for our
|
||||
# platform. Rebuild it explicitly so prebuild-install runs. python3 +
|
||||
# make + g++ are the fallback toolchain if no prebuilt matches (e.g.
|
||||
# on uncommon arches); on linux-x64/arm64 the prebuild downloads in
|
||||
# seconds and the compiler is never invoked. This stage is discarded
|
||||
# from the final image, so the install footprint doesn't matter.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ \
|
||||
&& npm rebuild better-sqlite3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Stage 2: Final runtime image ───────────────────────────
|
||||
FROM node:20-slim AS runner
|
||||
|
||||
|
||||
Vendored
+2297
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -0,0 +1,385 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Sign in to Recaps</title>
|
||||
|
||||
<!-- Match index.html's PWA setup so a user installed via the
|
||||
Add-to-Home-Screen flow can land on /auth.html (signed-out)
|
||||
without losing the standalone display + theme colors. -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#0a0e1a">
|
||||
<link rel="apple-touch-icon" href="/assets/icon.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Recaps">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
|
||||
<!-- Social preview tags — same as index.html so any shared
|
||||
/auth.html link previews cleanly too. -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Recaps">
|
||||
<meta property="og:title" content="Sign in to Recaps">
|
||||
<meta property="og:description" content="Summarize any YouTube video or podcast episode into topic-level summaries with timestamps.">
|
||||
<meta property="og:url" content="https://recaps.cc/auth.html">
|
||||
<meta property="og:image" content="https://recaps.cc/assets/icon.png">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Sign in to Recaps">
|
||||
<meta name="twitter:image" content="https://recaps.cc/assets/icon.png">
|
||||
|
||||
<link rel="icon" type="image/png" href="/assets/icon.png">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0a0e1a;
|
||||
color: #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: #121828;
|
||||
border: 1px solid #1f2942;
|
||||
border-radius: 12px;
|
||||
padding: 32px 28px;
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.logo img { width: 32px; height: 32px; border-radius: 6px; }
|
||||
.logo span { font-size: 18px; font-weight: 600; color: #f5f9ff; }
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #f5f9ff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
p.lede {
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
input[type=email],
|
||||
input[type=password] {
|
||||
width: 100%;
|
||||
background: #0a0e1a;
|
||||
border: 1px solid #1f2942;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
color: #f5f9ff;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
/* Browsers auto-fill password fields with their own bright
|
||||
background; -webkit-text-fill-color + a long inset shadow
|
||||
override that so the field stays on-brand. */
|
||||
-webkit-text-fill-color: #f5f9ff;
|
||||
-webkit-box-shadow: 0 0 0 1000px #0a0e1a inset;
|
||||
caret-color: #f5f9ff;
|
||||
}
|
||||
input[type=email]:focus,
|
||||
input[type=password]:focus { border-color: #3b82f6; }
|
||||
input[type=email]::placeholder,
|
||||
input[type=password]::placeholder { color: #475569; }
|
||||
button {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #2563eb; }
|
||||
button:disabled { background: #1e3a8a; cursor: not-allowed; opacity: 0.6; }
|
||||
.feedback {
|
||||
margin-top: 20px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
display: none;
|
||||
}
|
||||
.feedback.success {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
color: #6ee7b7;
|
||||
display: block;
|
||||
}
|
||||
.feedback.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
display: block;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 28px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.footer a { color: #94a3b8; text-decoration: none; }
|
||||
.footer a:hover { color: #cbd5e1; }
|
||||
/* Password group hidden by default — most users want the magic
|
||||
link and the optional-password field cluttered the form. The
|
||||
"Use password instead" link below the submit button reveals
|
||||
this when needed. */
|
||||
.password-group[hidden] { display: none; }
|
||||
.toggle-pwd-row {
|
||||
text-align: center;
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
.toggle-pwd-row a {
|
||||
color: #94a3b8;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle-pwd-row a:hover { color: #cbd5e1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<img src="/assets/icon.png" alt="Recaps" onerror="this.style.display='none'">
|
||||
<span>Recaps</span>
|
||||
</div>
|
||||
|
||||
<h1 id="auth-heading">Sign in</h1>
|
||||
<p class="lede" id="auth-lede">
|
||||
Enter your email — we'll send a sign-in link.
|
||||
</p>
|
||||
|
||||
<form id="signin-form" autocomplete="on">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
|
||||
<div class="password-group" id="password-group" hidden>
|
||||
<label for="password" style="margin-top:12px;">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submit-btn">Send sign-in link</button>
|
||||
</form>
|
||||
|
||||
<div class="toggle-pwd-row" id="toggle-pwd-row">
|
||||
<a id="toggle-pwd" role="button" tabindex="0">Use password instead</a>
|
||||
</div>
|
||||
|
||||
<div class="feedback" id="feedback"></div>
|
||||
|
||||
<div class="footer">
|
||||
First time here? We'll create your account when you click the sign-in link.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('signin-form');
|
||||
const btn = document.getElementById('submit-btn');
|
||||
const feedback = document.getElementById('feedback');
|
||||
const emailInput = document.getElementById('email');
|
||||
const passwordInput = document.getElementById('password');
|
||||
const passwordGroup = document.getElementById('password-group');
|
||||
const togglePwd = document.getElementById('toggle-pwd');
|
||||
const togglePwdRow = document.getElementById('toggle-pwd-row');
|
||||
const heading = document.getElementById('auth-heading');
|
||||
const lede = document.getElementById('auth-lede');
|
||||
|
||||
// Reveal the password field on demand. Most users sign in via
|
||||
// magic link so the password field is clutter by default; this
|
||||
// toggle surfaces it when needed. Once revealed it stays open
|
||||
// for the rest of the session — no "hide again" affordance
|
||||
// because re-hiding after typing would lose state and confuse.
|
||||
function revealPasswordField() {
|
||||
passwordGroup.hidden = false;
|
||||
togglePwdRow.style.display = 'none';
|
||||
// Defer focus to give the browser a tick to lay out the field.
|
||||
setTimeout(() => passwordInput.focus(), 0);
|
||||
updateBtnLabel();
|
||||
}
|
||||
togglePwd.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
revealPasswordField();
|
||||
});
|
||||
togglePwd.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
revealPasswordField();
|
||||
}
|
||||
});
|
||||
|
||||
// ?intent=signup vs ?intent=signin — same form, slightly different
|
||||
// copy. Signup leans "we'll create your account when you click
|
||||
// the link"; signin leans "welcome back". If neither param is
|
||||
// present we default to the generic Sign in copy (current
|
||||
// behavior).
|
||||
(function tailorIntent() {
|
||||
try {
|
||||
const intent = new URLSearchParams(location.search).get('intent');
|
||||
if (intent === 'signup') {
|
||||
document.title = 'Create your Recaps account';
|
||||
heading.textContent = 'Create your account';
|
||||
lede.textContent = "Enter your email — we'll send a sign-in link and create your account when you click it. No password to set up.";
|
||||
} else if (intent === 'signin') {
|
||||
document.title = 'Sign in to Recaps';
|
||||
heading.textContent = 'Sign in';
|
||||
// lede stays as the default
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
function setFeedback(msg, kind) {
|
||||
feedback.textContent = msg;
|
||||
feedback.className = 'feedback ' + kind;
|
||||
}
|
||||
function clearFeedback() {
|
||||
feedback.textContent = '';
|
||||
feedback.className = 'feedback';
|
||||
}
|
||||
|
||||
// Submit button label tracks whether the password field is in
|
||||
// play. Hidden + empty = "Send sign-in link" (the dominant case);
|
||||
// revealed + filled = "Sign in"; revealed + empty falls back to
|
||||
// "Send sign-in link" so the user can change their mind without
|
||||
// re-toggling the field.
|
||||
function updateBtnLabel() {
|
||||
if (btn.disabled) return;
|
||||
const usingPwd = !passwordGroup.hidden && passwordInput.value.length > 0;
|
||||
btn.textContent = usingPwd ? 'Sign in' : 'Send sign-in link';
|
||||
}
|
||||
passwordInput.addEventListener('input', updateBtnLabel);
|
||||
updateBtnLabel();
|
||||
|
||||
async function signInWithPassword(email, password) {
|
||||
const res = await fetch('/auth/signin-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Cookie set server-side. Land on the main app.
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
if (res.status === 429) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setFeedback(body.message || 'Too many attempts. Try again later.', 'error');
|
||||
} else if (res.status === 401) {
|
||||
setFeedback('Email or password is wrong. Leave the password blank to receive a sign-in link instead.', 'error');
|
||||
} else {
|
||||
setFeedback('Something went wrong. Please try again.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
clearFeedback();
|
||||
const email = emailInput.value.trim();
|
||||
// Only honor the password value if the user explicitly opened
|
||||
// the password section. Otherwise a browser autofill into the
|
||||
// hidden field would force the form into password-sign-in mode
|
||||
// against the user's intent.
|
||||
const password = passwordGroup.hidden ? '' : passwordInput.value;
|
||||
if (!email) return;
|
||||
|
||||
btn.disabled = true;
|
||||
const usingPassword = password.length > 0;
|
||||
btn.textContent = usingPassword ? 'Signing in...' : 'Sending...';
|
||||
|
||||
if (usingPassword) {
|
||||
try {
|
||||
await signInWithPassword(email, password);
|
||||
} catch (err) {
|
||||
setFeedback('Network error. Check your connection and try again.', 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
updateBtnLabel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Silent retry on the first fetch — iOS Safari sometimes
|
||||
// aborts the very first request from a cold tab with a generic
|
||||
// "Load failed" TypeError. A single ~500ms retry hides the
|
||||
// flake; server-side errors (4xx/5xx) are not retried because
|
||||
// they're deliberate responses, not transport issues.
|
||||
const reqInit = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
};
|
||||
async function postWithRetry() {
|
||||
try {
|
||||
return await fetch('/auth/request-link', reqInit);
|
||||
} catch (e) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
return await fetch('/auth/request-link', reqInit);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await postWithRetry();
|
||||
if (res.status === 429) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setFeedback(body.message || 'Too many requests. Try again later.', 'error');
|
||||
} else if (res.status === 503) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setFeedback(
|
||||
body.message || 'Sign-in is temporarily unavailable.',
|
||||
'error',
|
||||
);
|
||||
} else if (!res.ok) {
|
||||
setFeedback('Something went wrong. Please try again.', 'error');
|
||||
} else {
|
||||
setFeedback(
|
||||
'Check your email — we sent a sign-in link to ' + email + '. It expires in 15 minutes.',
|
||||
'success',
|
||||
);
|
||||
form.reset();
|
||||
}
|
||||
} catch (err) {
|
||||
setFeedback('Network error. Check your connection and try again.', 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
updateBtnLabel();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+6744
-175
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "Recap",
|
||||
"short_name": "Recap",
|
||||
"description": "Summarize YouTube videos and podcasts. Paste a link, get topic-level summaries with timestamps.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0e1a",
|
||||
"theme_color": "#0a0e1a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/icon.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/icon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/icon.png",
|
||||
"sizes": "1024x1024",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["productivity", "utilities"]
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// Per-user "my account" endpoints for multi-tenant Recap. Distinct
|
||||
// from admin-routes.js — these are scoped to the currently signed-in
|
||||
// user (req.user), not to whichever tenant id you pass in the URL.
|
||||
// Used by the lite settings panel to render "Active sessions" and
|
||||
// "Sign out everywhere" actions for a tenant managing their own
|
||||
// account.
|
||||
//
|
||||
// Endpoints:
|
||||
// GET /api/account/sessions — my active sessions
|
||||
// DELETE /api/account/sessions/:sessionId — revoke a specific session of mine
|
||||
// POST /api/account/sessions/revoke-others — revoke everything BUT the current session
|
||||
//
|
||||
// Multi-mode only. Single mode never mounts these — the synthetic
|
||||
// "owner" user has no session table to manage.
|
||||
|
||||
import { getDb } from "./db.js";
|
||||
import { requireUser } from "./tenant-auth.js";
|
||||
import { hashPassword, validatePasswordPolicy } from "./auth-routes.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { getHistoryDir } from "./history.js";
|
||||
|
||||
export function setupAccountRoutes(app) {
|
||||
// ── My license key (for the "Take Recap home" flow) ────────────────
|
||||
// The Pro/Max license is a bearer credential — anyone with the LIC1-
|
||||
// string can present it to the relay. We only return the CALLING
|
||||
// user's own key (gated by req.user.id), never anyone else's. The key
|
||||
// is stored on users.keysat_license; the parsed entitlement state
|
||||
// already comes via /api/license-status. This endpoint is the one
|
||||
// place the raw string is exposed — used by the lite settings panel
|
||||
// to show a copy-to-clipboard "Take Recap home" block for paid
|
||||
// tenants who want to also run Recap on their own StartOS server.
|
||||
app.get("/api/account/license-key", requireUser, (req, res) => {
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(403).json({ error: "no_user" });
|
||||
}
|
||||
try {
|
||||
const row = getDb()
|
||||
.prepare("SELECT keysat_license FROM users WHERE id = ?")
|
||||
.get(req.user.id);
|
||||
const key = (row?.keysat_license || "").trim();
|
||||
if (!key) {
|
||||
return res.status(404).json({ error: "no_license" });
|
||||
}
|
||||
res.json({ license_key: key });
|
||||
} catch (err) {
|
||||
console.error("[account] license-key lookup failed:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// List MY active sessions (the device list).
|
||||
app.get("/api/account/sessions", requireUser, (req, res) => {
|
||||
// Trial users (req.userId starts with "anon:") don't have a row in
|
||||
// the sessions table — they're tracked via anon_trials. Bail with
|
||||
// an empty list so the UI doesn't error.
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.json({ sessions: [], current_session_id: null });
|
||||
}
|
||||
try {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT id, created_at, expires_at, last_used_at, user_agent, ip_address
|
||||
FROM sessions
|
||||
WHERE user_id = ? AND expires_at > ?
|
||||
ORDER BY last_used_at DESC, created_at DESC`,
|
||||
)
|
||||
.all(req.user.id, Date.now());
|
||||
res.json({
|
||||
sessions: rows,
|
||||
// Tell the UI which row is the CURRENT session so it can
|
||||
// disable the "Revoke" button for that one (sign out instead).
|
||||
current_session_id: req.session?.id || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[account] sessions list failed:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
});
|
||||
|
||||
// Revoke a single session of mine. The session id MUST belong to me
|
||||
// (we WHERE clause both id AND user_id). Otherwise a tenant could
|
||||
// delete someone else's session by guessing the id.
|
||||
app.delete(
|
||||
"/api/account/sessions/:sessionId",
|
||||
requireUser,
|
||||
(req, res) => {
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(400).json({ error: "trial_has_no_sessions" });
|
||||
}
|
||||
const sessionId = req.params.sessionId;
|
||||
try {
|
||||
const result = getDb()
|
||||
.prepare("DELETE FROM sessions WHERE id = ? AND user_id = ?")
|
||||
.run(sessionId, req.user.id);
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: "session_not_found" });
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("[account] revoke session failed:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Set / clear my password ────────────────────────────────────────
|
||||
// Magic-link is the primary auth. Setting a password is optional —
|
||||
// makes returning sign-in faster. Set and "change" use the same
|
||||
// endpoint (overwrite); the reset flow is "sign in via magic link,
|
||||
// then call this endpoint with the new password."
|
||||
//
|
||||
// POST /api/account/password { password } — set or overwrite
|
||||
// DELETE /api/account/password — clear / revert to
|
||||
// magic-link-only
|
||||
//
|
||||
// Lives in /api/account/* (not /auth/*) because both require an
|
||||
// active session and need the tenant-auth middleware to attach
|
||||
// req.user — /auth/* is the public-path namespace bypassed by the
|
||||
// middleware so unauthenticated visitors can request links / verify.
|
||||
app.post("/api/account/password", requireUser, (req, res) => {
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(401).json({ error: "auth_required" });
|
||||
}
|
||||
const password = req.body?.password;
|
||||
const policyErr = validatePasswordPolicy(password);
|
||||
if (policyErr) {
|
||||
return res.status(400).json({
|
||||
error: policyErr,
|
||||
message:
|
||||
policyErr === "password_too_short"
|
||||
? "Use at least 8 characters."
|
||||
: policyErr === "password_too_long"
|
||||
? "256 characters max."
|
||||
: "Pick a password.",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const hash = hashPassword(password);
|
||||
getDb()
|
||||
.prepare("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
.run(hash, req.user.id);
|
||||
console.log(`[account] password set for user ${req.user.id}`);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("[account] set-password failed:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/account/password", requireUser, (req, res) => {
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(401).json({ error: "auth_required" });
|
||||
}
|
||||
try {
|
||||
getDb()
|
||||
.prepare("UPDATE users SET password_hash = NULL WHERE id = ?")
|
||||
.run(req.user.id);
|
||||
console.log(`[account] password cleared for user ${req.user.id}`);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("[account] clear-password failed:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Delete my account ──────────────────────────────────────────────
|
||||
// GDPR-style hard delete of the calling user. Confirms via a body
|
||||
// sentinel ({confirm: "DELETE"}) so a stray DELETE request can't
|
||||
// wipe an account by accident. After deletion the session cookie is
|
||||
// cleared; the user lands back on the anonymous landing page.
|
||||
//
|
||||
// Cascades (via the schema's ON DELETE CASCADE) handle:
|
||||
// - sessions (drop everywhere)
|
||||
// - tenant_credits (drop balance row)
|
||||
// - library_meta (drop the index entries)
|
||||
// - subscriptions (drop billing history)
|
||||
// - magic_link_tokens stay (they have no FK to users — they're
|
||||
// keyed by email, harmless to leave)
|
||||
// - anon_trials.converted_to_user_id SET NULL (analytics row stays)
|
||||
//
|
||||
// The filesystem-side history folder /data/history/<user_id>/ is
|
||||
// removed separately — SQLite cascades don't reach the FS. We do
|
||||
// this AFTER the DB delete so partial failures don't leave dangling
|
||||
// metadata.
|
||||
//
|
||||
// Admin users CANNOT delete themselves via this endpoint — that
|
||||
// would leave the multi-tenant Recap orphaned with no admin. They
|
||||
// can downgrade themselves first via SQL if they really mean it.
|
||||
app.delete("/api/account", requireUser, async (req, res) => {
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(403).json({ error: "no_user" });
|
||||
}
|
||||
if (req.user.is_admin) {
|
||||
return res.status(400).json({
|
||||
error: "cannot_self_delete_admin",
|
||||
message:
|
||||
"Operator account can't be self-deleted (no admin would be left). Demote yourself first.",
|
||||
});
|
||||
}
|
||||
if (req.body?.confirm !== "DELETE") {
|
||||
return res.status(400).json({
|
||||
error: "confirmation_required",
|
||||
message: "Send {confirm: \"DELETE\"} in the body to confirm.",
|
||||
});
|
||||
}
|
||||
const userId = req.user.id;
|
||||
try {
|
||||
// FK cascade handles sessions, tenant_credits, library_meta,
|
||||
// subscriptions. Run inside a transaction so a crash mid-delete
|
||||
// doesn't leave the user partially intact.
|
||||
const db = getDb();
|
||||
const result = db
|
||||
.transaction(() => {
|
||||
return db
|
||||
.prepare("DELETE FROM users WHERE id = ?")
|
||||
.run(userId);
|
||||
})();
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: "user_not_found" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[account] delete user DB op failed:", err);
|
||||
return res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
// FS cleanup. Best-effort — failures here don't roll the user
|
||||
// back into existence. Worst case is an orphan folder that the
|
||||
// operator can sweep manually.
|
||||
try {
|
||||
const userDir = path.join(getHistoryDir(), userId);
|
||||
await fs.rm(userDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[account] history dir cleanup failed for ${userId} (DB delete succeeded):`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
// Clear the session cookie so the next request from this browser
|
||||
// is anonymous, not "stale-session-401-prompted-to-sign-in".
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
"recap_session=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure",
|
||||
);
|
||||
console.log(`[account] user ${userId} deleted their account`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// "Sign out everywhere except this device" — useful after a
|
||||
// suspicious-login email or just for hygiene. Deletes every session
|
||||
// for this user EXCEPT the one carrying the request.
|
||||
app.post(
|
||||
"/api/account/sessions/revoke-others",
|
||||
requireUser,
|
||||
(req, res) => {
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(400).json({ error: "trial_has_no_sessions" });
|
||||
}
|
||||
const currentSessionId = req.session?.id;
|
||||
try {
|
||||
let result;
|
||||
if (currentSessionId) {
|
||||
result = getDb()
|
||||
.prepare(
|
||||
"DELETE FROM sessions WHERE user_id = ? AND id != ?",
|
||||
)
|
||||
.run(req.user.id, currentSessionId);
|
||||
} else {
|
||||
// No current session detected (shouldn't happen if requireUser
|
||||
// passed, but defensively): treat as "revoke all of mine."
|
||||
result = getDb()
|
||||
.prepare("DELETE FROM sessions WHERE user_id = ?")
|
||||
.run(req.user.id);
|
||||
}
|
||||
res.json({ ok: true, revoked: result.changes });
|
||||
} catch (err) {
|
||||
console.error("[account] revoke-others failed:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
// Operator (is_admin = 1) endpoints for multi-tenant Recap. Every
|
||||
// route here is gated by requireOperator from tenant-auth.js — non-
|
||||
// admin users get 403. Single-mode never mounts these.
|
||||
//
|
||||
// What's exposed:
|
||||
// GET /api/admin/tenants — list all users + credits + license status
|
||||
// POST /api/admin/tenants/:id/grant — add credits to a user's local balance
|
||||
// POST /api/admin/tenants/:id/tier — set a user's subscription tier (relay-owned)
|
||||
// GET /api/admin/tenants/:id/sessions — list a user's active sessions
|
||||
// DELETE /api/admin/tenants/:id/sessions — revoke ALL of a user's sessions
|
||||
// DELETE /api/admin/sessions/:sessionId — revoke a specific session
|
||||
// GET /api/admin/recent-signups — signups grouped by IP/UA in the last N hours
|
||||
//
|
||||
// All responses are JSON. Tenant rows shape:
|
||||
// { id, email, display_name, is_admin, tier, has_license, balance,
|
||||
// lifetime_granted, lifetime_consumed, created_at, last_signin_at,
|
||||
// signup_ip, signup_user_agent, session_count }
|
||||
//
|
||||
// Volume notes: SQLite, ~100s of tenants tops for an alpha cohort.
|
||||
// No pagination yet — caps at LIMIT 500 to be defensive.
|
||||
|
||||
import { getDb } from "./db.js";
|
||||
import { requireOperator } from "./tenant-auth.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { getHistoryDir } from "./history.js";
|
||||
import { getRelayOperatorKey } from "./relay-default.js";
|
||||
|
||||
const MAX_TENANT_ROWS = 500;
|
||||
const RECENT_SIGNUPS_MAX_HOURS = 24 * 14; // 2 weeks
|
||||
|
||||
export function setupAdminRoutes(app) {
|
||||
// ── Run / test the subscription expiry-reminder scan ───────────────────
|
||||
// Operator-only. With { test_email } it sends a SAMPLE reminder to that
|
||||
// address — verifies the recaps.cc SMTP + the email rendering without
|
||||
// needing a real near-expiry subscription. Without it, forces an
|
||||
// immediate scan of real expiring subscriptions and returns the summary
|
||||
// ({ sent, skipped } or a { skipped: <reason> } if a precondition fails).
|
||||
app.post("/api/admin/reminders/run", requireOperator, async (req, res) => {
|
||||
try {
|
||||
const testEmail =
|
||||
typeof req.body?.test_email === "string" ? req.body.test_email.trim() : "";
|
||||
if (testEmail) {
|
||||
const { isSmtpReady, sendMail } = await import("./smtp.js");
|
||||
if (!isSmtpReady()) {
|
||||
return res.status(503).json({
|
||||
error: "smtp_not_ready",
|
||||
message: "Configure StartOS System SMTP first.",
|
||||
});
|
||||
}
|
||||
const { renderSubscriptionReminderEmail } = await import(
|
||||
"./email-template.js"
|
||||
);
|
||||
const { getConfigSnapshot } = await import("./config.js");
|
||||
const snap = await getConfigSnapshot();
|
||||
const publicUrl = (snap.recap_public_url || "")
|
||||
.trim()
|
||||
.replace(/\/$/, "");
|
||||
const msg = renderSubscriptionReminderEmail({
|
||||
brandName: "Recaps",
|
||||
tier: "pro",
|
||||
expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
|
||||
daysLeft: 7,
|
||||
kind: "upcoming_7d",
|
||||
manageUrl: `${publicUrl}/?renew=1`,
|
||||
});
|
||||
await sendMail({
|
||||
to: testEmail,
|
||||
subject: msg.subject,
|
||||
text: msg.text,
|
||||
html: msg.html,
|
||||
});
|
||||
return res.json({ ok: true, test_email_sent_to: testEmail });
|
||||
}
|
||||
const { runReminderScan } = await import("./subscription-reminders.js");
|
||||
const result = await runReminderScan({ force: true });
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
console.error("[admin] reminder run failed:", err?.message || err);
|
||||
res.status(500).json({
|
||||
error: "reminder_run_failed",
|
||||
message: err?.message || String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── List all tenants ───────────────────────────────────────────────────
|
||||
app.get("/api/admin/tenants", requireOperator, (req, res) => {
|
||||
try {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT
|
||||
u.id, u.email, u.display_name, u.is_admin, u.tier,
|
||||
u.created_at, u.last_signin_at,
|
||||
u.signup_ip, u.signup_user_agent,
|
||||
CASE WHEN u.keysat_license IS NOT NULL AND length(u.keysat_license) > 0 THEN 1 ELSE 0 END AS has_license,
|
||||
COALESCE(tc.purchased_balance, 0) AS purchased_balance,
|
||||
COALESCE(tc.replenish_balance, 0) AS replenish_balance,
|
||||
COALESCE(tc.purchased_balance, 0) + COALESCE(tc.replenish_balance, 0) AS balance,
|
||||
COALESCE(tc.lifetime_granted, 0) AS lifetime_granted,
|
||||
COALESCE(tc.lifetime_consumed, 0) AS lifetime_consumed,
|
||||
(SELECT COUNT(*) FROM sessions s
|
||||
WHERE s.user_id = u.id AND s.expires_at > ?) AS session_count
|
||||
FROM users u
|
||||
LEFT JOIN tenant_credits tc ON tc.user_id = u.id
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(Date.now(), MAX_TENANT_ROWS);
|
||||
// The relay-owned tier can only be set when this server holds the
|
||||
// matching operator key (otherwise the grant just 502s). The UI hides
|
||||
// the per-row "Tier" control when this is false, so a self-hosted
|
||||
// operator — who has no matching key for the canonical relay — doesn't
|
||||
// see a button that can't work.
|
||||
res.json({
|
||||
tenants: rows,
|
||||
relay_operator_key_configured: !!getRelayOperatorKey(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[admin] /tenants failed:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Grant credits to a tenant ──────────────────────────────────────────
|
||||
// Body: { amount }. Positive integer.
|
||||
//
|
||||
// Upserts tenant_credits and increments balance + lifetime_granted.
|
||||
// Records the operator's id + a timestamp so we have an audit trail
|
||||
// when /api/admin/audit lands later (currently the grant is implicit
|
||||
// via lifetime_granted; the trail can be reconstructed by diffing).
|
||||
app.post(
|
||||
"/api/admin/tenants/:id/grant",
|
||||
requireOperator,
|
||||
async (req, res) => {
|
||||
const userId = req.params.id;
|
||||
const amount = parseInt(req.body?.amount, 10);
|
||||
if (!Number.isFinite(amount) || amount <= 0 || amount > 100000) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "bad_amount", message: "amount must be 1..100000" });
|
||||
}
|
||||
try {
|
||||
const db = getDb();
|
||||
// Ensure the user exists before we upsert credits.
|
||||
const user = db
|
||||
.prepare("SELECT id, email FROM users WHERE id = ?")
|
||||
.get(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "user_not_found" });
|
||||
}
|
||||
// Admin grants go into the PURCHASED bucket — they're explicit
|
||||
// operator-initiated credits and shouldn't get wiped by the
|
||||
// next replenishment cycle. Helper handles upsert + lifetime_granted.
|
||||
const { addPurchased } = await import("./tenant-credits.js");
|
||||
const row = addPurchased(userId, amount);
|
||||
const total =
|
||||
(row?.purchased_balance || 0) + (row?.replenish_balance || 0);
|
||||
console.log(
|
||||
`[admin] granted ${amount} credits to ${user.email} (by ${req.user.email}) — total balance ${total}`,
|
||||
);
|
||||
res.json({
|
||||
ok: true,
|
||||
user_id: userId,
|
||||
balance: total,
|
||||
purchased_balance: row?.purchased_balance || 0,
|
||||
replenish_balance: row?.replenish_balance || 0,
|
||||
granted: amount,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[admin] grant failed:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Set a tenant's subscription tier ───────────────────────────────────
|
||||
// Body: { tier }. One of "core" | "pro" | "max".
|
||||
//
|
||||
// Core-decoupling: the RELAY owns the subscription (keyed by Recaps
|
||||
// user-id), NOT a per-user Keysat license. This route writes the
|
||||
// relay-side tier FIRST (the authoritative owner), and only on success
|
||||
// caches it on the local users row. That ordering keeps the two from
|
||||
// drifting — we never show a user as Pro in the UI while the relay still
|
||||
// rejects their cloud calls. The cached users.tier is what the per-user
|
||||
// feature gates (tts-routes, license-status) and the providers'
|
||||
// cloud-identity selection read on each request.
|
||||
app.post(
|
||||
"/api/admin/tenants/:id/tier",
|
||||
requireOperator,
|
||||
async (req, res) => {
|
||||
const userId = req.params.id;
|
||||
const tier = String(req.body?.tier || "").trim().toLowerCase();
|
||||
if (!["core", "pro", "max"].includes(tier)) {
|
||||
return res.status(400).json({
|
||||
error: "bad_tier",
|
||||
message: 'tier must be "core", "pro", or "max"',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const db = getDb();
|
||||
const user = db
|
||||
.prepare("SELECT id, email FROM users WHERE id = ?")
|
||||
.get(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "user_not_found" });
|
||||
}
|
||||
// 1) Authoritative write to the relay (the subscription owner).
|
||||
// Throws if the operator key / relay base URL isn't configured,
|
||||
// or if the relay rejects the call — in which case we DON'T
|
||||
// touch the local cache.
|
||||
const { setRelayUserTier } = await import("./providers/relay.js");
|
||||
try {
|
||||
await setRelayUserTier({ userId, tier });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[admin] relay tier push failed:",
|
||||
err?.message || err,
|
||||
);
|
||||
return res.status(502).json({
|
||||
error: "relay_tier_failed",
|
||||
message:
|
||||
"Couldn't set the tier on the relay (the subscription owner). " +
|
||||
"Check the relay operator key + base URL, then retry. " +
|
||||
(err?.message || ""),
|
||||
});
|
||||
}
|
||||
// 2) Cache locally so feature gates + the badge see it immediately.
|
||||
db.prepare("UPDATE users SET tier = ? WHERE id = ?").run(tier, userId);
|
||||
console.log(
|
||||
`[admin] set tier=${tier} for ${user.email} (by ${req.user.email})`,
|
||||
);
|
||||
res.json({ ok: true, user_id: userId, tier });
|
||||
} catch (err) {
|
||||
console.error("[admin] set tier failed:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── List a tenant's active sessions ────────────────────────────────────
|
||||
app.get(
|
||||
"/api/admin/tenants/:id/sessions",
|
||||
requireOperator,
|
||||
(req, res) => {
|
||||
const userId = req.params.id;
|
||||
try {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT id, created_at, expires_at, last_used_at, user_agent, ip_address
|
||||
FROM sessions
|
||||
WHERE user_id = ? AND expires_at > ?
|
||||
ORDER BY last_used_at DESC, created_at DESC`,
|
||||
)
|
||||
.all(userId, Date.now());
|
||||
res.json({ sessions: rows });
|
||||
} catch (err) {
|
||||
console.error("[admin] sessions list failed:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Revoke ALL sessions for a tenant ───────────────────────────────────
|
||||
// Operator-side "sign this user out everywhere" button. Useful when
|
||||
// a tenant flags account theft, or for ban-hammer scenarios. The
|
||||
// user can sign back in via magic link unless the operator also
|
||||
// disables their email (future feature).
|
||||
app.delete(
|
||||
"/api/admin/tenants/:id/sessions",
|
||||
requireOperator,
|
||||
(req, res) => {
|
||||
const userId = req.params.id;
|
||||
try {
|
||||
const result = getDb()
|
||||
.prepare("DELETE FROM sessions WHERE user_id = ?")
|
||||
.run(userId);
|
||||
console.log(
|
||||
`[admin] revoked ${result.changes} session(s) for user ${userId} (by ${req.user.email})`,
|
||||
);
|
||||
res.json({ ok: true, revoked: result.changes });
|
||||
} catch (err) {
|
||||
console.error("[admin] revoke all sessions failed:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Revoke a single session by id ──────────────────────────────────────
|
||||
app.delete(
|
||||
"/api/admin/sessions/:sessionId",
|
||||
requireOperator,
|
||||
(req, res) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
try {
|
||||
const result = getDb()
|
||||
.prepare("DELETE FROM sessions WHERE id = ?")
|
||||
.run(sessionId);
|
||||
res.json({ ok: true, revoked: result.changes });
|
||||
} catch (err) {
|
||||
console.error("[admin] revoke session failed:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Delete a tenant (admin ban-hammer) ─────────────────────────────────
|
||||
// Operator removes a tenant — abuse response, account cleanup, etc.
|
||||
// Mirrors the user-side /api/account DELETE but operator-scoped:
|
||||
// - Refuses to delete another admin (admins protect each other)
|
||||
// - Refuses to delete the calling operator (use the user-side
|
||||
// endpoint or SQL if you really need that)
|
||||
// - Cascades sessions, tenant_credits, library_meta via FK ON DELETE
|
||||
// - FS cleanup of /data/history/<user_id>/ is best-effort
|
||||
app.delete(
|
||||
"/api/admin/tenants/:id",
|
||||
requireOperator,
|
||||
async (req, res) => {
|
||||
const userId = req.params.id;
|
||||
if (userId === req.user.id) {
|
||||
return res.status(400).json({
|
||||
error: "cannot_delete_self",
|
||||
message:
|
||||
"Use the user-side endpoint to delete your own account, or demote first.",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const db = getDb();
|
||||
const target = db
|
||||
.prepare("SELECT id, email, is_admin FROM users WHERE id = ?")
|
||||
.get(userId);
|
||||
if (!target) {
|
||||
return res.status(404).json({ error: "user_not_found" });
|
||||
}
|
||||
if (target.is_admin) {
|
||||
return res.status(400).json({
|
||||
error: "cannot_delete_admin",
|
||||
message:
|
||||
"Demote this user from admin first if you really want to delete them.",
|
||||
});
|
||||
}
|
||||
db.prepare("DELETE FROM users WHERE id = ?").run(userId);
|
||||
console.log(
|
||||
`[admin] deleted tenant ${target.email} (id ${userId}) (by ${req.user.email})`,
|
||||
);
|
||||
// FS cleanup, best-effort.
|
||||
try {
|
||||
const userDir = path.join(getHistoryDir(), userId);
|
||||
await fs.rm(userDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[admin] history-dir cleanup failed for ${userId}:`,
|
||||
err?.message || err,
|
||||
);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("[admin] delete tenant failed:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Recent signups (forensic / abuse detection) ────────────────────────
|
||||
// Query: ?hours=N (default 24, clamped to RECENT_SIGNUPS_MAX_HOURS).
|
||||
// Returns:
|
||||
// {
|
||||
// window_hours: 24,
|
||||
// by_ip: [{ ip, count, emails[], first_seen, last_seen }],
|
||||
// by_ua: [{ ua, count, ... }],
|
||||
// by_hour: [{ hour, count }],
|
||||
// totals: { signups, magic_link_requests }
|
||||
// }
|
||||
app.get("/api/admin/recent-signups", requireOperator, (req, res) => {
|
||||
const hoursReq = parseInt(req.query?.hours, 10) || 24;
|
||||
const hours = Math.min(
|
||||
Math.max(1, hoursReq),
|
||||
RECENT_SIGNUPS_MAX_HOURS,
|
||||
);
|
||||
const cutoff = Date.now() - hours * 60 * 60 * 1000;
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
// Tracked sources:
|
||||
// - users.signup_* — confirmed signups (someone clicked magic link)
|
||||
// - magic_link_tokens.request_* — link requests, includes unconverted
|
||||
//
|
||||
// Top-line metrics show both so the operator can spot patterns
|
||||
// like "10000 link requests, 2 actual signups" → scripted abuse.
|
||||
|
||||
// Signups by IP
|
||||
const byIp = db
|
||||
.prepare(
|
||||
`SELECT signup_ip AS ip, COUNT(*) AS count,
|
||||
MIN(created_at) AS first_seen,
|
||||
MAX(created_at) AS last_seen,
|
||||
GROUP_CONCAT(email, '|') AS emails_joined
|
||||
FROM users
|
||||
WHERE created_at > ? AND signup_ip IS NOT NULL AND signup_ip != ''
|
||||
GROUP BY signup_ip
|
||||
ORDER BY count DESC, last_seen DESC
|
||||
LIMIT 50`,
|
||||
)
|
||||
.all(cutoff)
|
||||
.map((r) => ({
|
||||
ip: r.ip,
|
||||
count: r.count,
|
||||
first_seen: r.first_seen,
|
||||
last_seen: r.last_seen,
|
||||
emails: (r.emails_joined || "").split("|").filter(Boolean),
|
||||
}));
|
||||
|
||||
// Signups by UA (truncated for readability — group on the first
|
||||
// 80 chars so "Mozilla/5.0 ... Chrome/120" rows collapse into
|
||||
// one even if patch versions differ slightly).
|
||||
const byUa = db
|
||||
.prepare(
|
||||
`SELECT substr(signup_user_agent, 1, 80) AS ua, COUNT(*) AS count,
|
||||
MIN(created_at) AS first_seen,
|
||||
MAX(created_at) AS last_seen
|
||||
FROM users
|
||||
WHERE created_at > ? AND signup_user_agent IS NOT NULL AND signup_user_agent != ''
|
||||
GROUP BY substr(signup_user_agent, 1, 80)
|
||||
ORDER BY count DESC, last_seen DESC
|
||||
LIMIT 50`,
|
||||
)
|
||||
.all(cutoff);
|
||||
|
||||
// Signups per hour (for a sparkline; small array).
|
||||
const byHour = db
|
||||
.prepare(
|
||||
`SELECT (created_at / 3600000) * 3600000 AS hour, COUNT(*) AS count
|
||||
FROM users
|
||||
WHERE created_at > ?
|
||||
GROUP BY hour
|
||||
ORDER BY hour ASC`,
|
||||
)
|
||||
.all(cutoff);
|
||||
|
||||
const signupCount = db
|
||||
.prepare("SELECT COUNT(*) AS n FROM users WHERE created_at > ?")
|
||||
.get(cutoff).n;
|
||||
const magicLinkCount = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS n FROM magic_link_tokens WHERE created_at > ?",
|
||||
)
|
||||
.get(cutoff).n;
|
||||
|
||||
// Magic-link request distribution by IP (catches "lots of
|
||||
// requests, no signups" abuse — abusers who keep requesting
|
||||
// links but never click them, or who can't because the email
|
||||
// isn't real). Truncate emails-joined to first 5 to keep
|
||||
// payload reasonable.
|
||||
const linkByIp = db
|
||||
.prepare(
|
||||
`SELECT request_ip AS ip, COUNT(*) AS count,
|
||||
MIN(created_at) AS first_seen,
|
||||
MAX(created_at) AS last_seen,
|
||||
COUNT(DISTINCT email) AS distinct_emails
|
||||
FROM magic_link_tokens
|
||||
WHERE created_at > ? AND request_ip IS NOT NULL AND request_ip != ''
|
||||
GROUP BY request_ip
|
||||
ORDER BY count DESC, last_seen DESC
|
||||
LIMIT 50`,
|
||||
)
|
||||
.all(cutoff);
|
||||
|
||||
res.json({
|
||||
window_hours: hours,
|
||||
totals: {
|
||||
signups: signupCount,
|
||||
magic_link_requests: magicLinkCount,
|
||||
},
|
||||
signups_by_ip: byIp,
|
||||
signups_by_ua: byUa,
|
||||
signups_by_hour: byHour,
|
||||
magic_links_by_ip: linkByIp,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[admin] recent-signups failed:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
// Cookie-gated "taste before sign-up" trial for unauthenticated
|
||||
// visitors on a multi-tenant Recap. First time a visitor POSTs to
|
||||
// /api/process without a session cookie, we issue a recap_anon_trial
|
||||
// cookie (32-byte random), insert an anon_trials row with N credits,
|
||||
// and let them summarize without signing up. After credits_used >=
|
||||
// credits_total, the UI nudges them to create an account.
|
||||
//
|
||||
// Trial summaries forward the OPERATOR's install_id + license to the
|
||||
// relay — they're paid for out of the operator's credit pool, gated
|
||||
// solely by the anon_trials.credits_total field. tenant_credits is
|
||||
// irrelevant for trials (no user row exists yet).
|
||||
//
|
||||
// Multi-mode only. Single-mode never imports this module.
|
||||
|
||||
import { randomBytes } from "crypto";
|
||||
import { getDb } from "./db.js";
|
||||
import { getConfigSnapshot } from "./config.js";
|
||||
|
||||
export const TRIAL_COOKIE = "recap_anon_trial";
|
||||
const TRIAL_COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
// Returns the trial config from the StartOS config snapshot. Cached
|
||||
// per-call by getConfigSnapshot (already polled by config.js), so
|
||||
// reading per-request is cheap.
|
||||
async function getTrialConfig() {
|
||||
const snap = await getConfigSnapshot();
|
||||
// Prefer the new `trials_per_ip_lifetime` field; fall back to the
|
||||
// legacy `trials_per_ip_per_day` so a config that hasn't been
|
||||
// re-saved under the new name keeps working with its existing value.
|
||||
// (Semantics is now lifetime in BOTH cases — the rename is mostly
|
||||
// cosmetic for the operator-facing knob.)
|
||||
const ipCap =
|
||||
snap.trials_per_ip_lifetime ??
|
||||
snap.trials_per_ip_per_day ??
|
||||
5;
|
||||
return {
|
||||
creditsPerVisitor: Math.max(
|
||||
0,
|
||||
parseInt(snap.trial_credits_per_visitor ?? 1, 10) || 0,
|
||||
),
|
||||
perIpLifetime: Math.max(1, parseInt(ipCap, 10) || 5),
|
||||
};
|
||||
}
|
||||
|
||||
// Crude IPv4-or-IPv6 string extraction. Trusts the X-Forwarded-For
|
||||
// header's first hop because Recap sits behind StartOS's tunnel — the
|
||||
// header is set by the operator's infrastructure, not by clients
|
||||
// directly. If you ever expose the server without a trusted proxy,
|
||||
// revisit this.
|
||||
export function getClientIp(req) {
|
||||
const xff = req.headers?.["x-forwarded-for"];
|
||||
if (xff) {
|
||||
const first = String(xff).split(",")[0].trim();
|
||||
if (first) return first;
|
||||
}
|
||||
return (req.socket?.remoteAddress || "").replace(/^::ffff:/, "");
|
||||
}
|
||||
|
||||
// Expand an IPv6 string to its full 8-group :-separated form with
|
||||
// each group lowercased + zero-padded to 4 hex chars. Returns null
|
||||
// if the input doesn't look like a valid IPv6 string. Used by
|
||||
// ipCapKey() below — we need a stable canonical form before we can
|
||||
// extract the /64 prefix for cap counting.
|
||||
function expandIpv6(addr) {
|
||||
if (!addr || typeof addr !== "string") return null;
|
||||
// Strip any IPv4-mapped suffix (e.g. ::ffff:1.2.3.4) — those are
|
||||
// really IPv4 addresses tunneled through an IPv6 envelope; the
|
||||
// caller has already stripped ::ffff: in getClientIp, but
|
||||
// defensively handle the bare form.
|
||||
if (/\d+\.\d+\.\d+\.\d+$/.test(addr)) return null;
|
||||
if (!addr.includes(":")) return null;
|
||||
const dblIdx = addr.indexOf("::");
|
||||
let groups;
|
||||
if (dblIdx === -1) {
|
||||
// No ::, must be 8 groups.
|
||||
groups = addr.split(":");
|
||||
if (groups.length !== 8) return null;
|
||||
} else {
|
||||
// :: shorthand — split into left and right halves, fill with 0s.
|
||||
if (addr.indexOf("::", dblIdx + 2) !== -1) return null; // two :: → invalid
|
||||
const left = addr.slice(0, dblIdx);
|
||||
const right = addr.slice(dblIdx + 2);
|
||||
const leftGroups = left ? left.split(":") : [];
|
||||
const rightGroups = right ? right.split(":") : [];
|
||||
const missing = 8 - leftGroups.length - rightGroups.length;
|
||||
if (missing < 0) return null;
|
||||
groups = [...leftGroups, ...Array(missing).fill("0"), ...rightGroups];
|
||||
}
|
||||
// Validate + normalize each group.
|
||||
const out = [];
|
||||
for (const g of groups) {
|
||||
if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;
|
||||
out.push(g.toLowerCase().padStart(4, "0"));
|
||||
}
|
||||
return out.join(":");
|
||||
}
|
||||
|
||||
// Cap-counting key for an IP. IPv4 returns the address unchanged
|
||||
// (whole-address caps work fine — only one device can claim a
|
||||
// public-routable IPv4). IPv6 returns the /64 prefix because RFC
|
||||
// 4941 privacy extensions rotate the lower 64 bits of the address
|
||||
// per-device, per-session, often hourly — counting by full IPv6
|
||||
// address means a single laptop can mint a fresh trial cookie
|
||||
// every time its OS picks a new temporary address. /64 is the
|
||||
// smallest network unit an ISP delegates to a subscriber, so it's
|
||||
// the correct boundary for "this household / this network" caps.
|
||||
// Returns null when the input isn't usable; callers treat null as
|
||||
// "no cap" (same fallback behavior as before).
|
||||
//
|
||||
// Trade-off: a /64 might be shared by multiple unrelated subscribers
|
||||
// in some carrier configurations, so legitimate visitors could
|
||||
// (rarely) get capped together. Operator can tune
|
||||
// trials_per_ip_lifetime higher if they're seeing real complaints.
|
||||
export function ipCapKey(ip) {
|
||||
if (!ip) return null;
|
||||
// IPv4: whole address.
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return ip;
|
||||
// IPv6: /64 prefix. Expand to full form, take first 4 groups,
|
||||
// append `:` so the SQL LIKE match doesn't accidentally match
|
||||
// unrelated prefixes that happen to share a textual leading
|
||||
// substring (e.g. "2600:17" prefix-matching "2600:171…").
|
||||
const expanded = expandIpv6(ip);
|
||||
if (!expanded) return null;
|
||||
const groups = expanded.split(":");
|
||||
if (groups.length < 4) return null;
|
||||
return groups.slice(0, 4).join(":") + ":";
|
||||
}
|
||||
|
||||
// Truncate UA so a pathological 8KB header doesn't bloat the DB. 256
|
||||
// chars covers every legit browser UA string with room to spare.
|
||||
function clipUA(ua) {
|
||||
if (!ua) return "";
|
||||
return String(ua).slice(0, 256);
|
||||
}
|
||||
|
||||
// Count ALL trial cookies ever issued from this IP. Switched from
|
||||
// rolling-24h to lifetime in 0.2.84 — a user who clears cookies can no
|
||||
// longer just wait a day and replay the trial. The trade-off: a
|
||||
// shared-NAT household whose router IP got 5 different family
|
||||
// members' trials over a year will eventually be capped. The
|
||||
// operator can tune `trials_per_ip_lifetime` higher if that's a
|
||||
// real concern, or grant credits manually from the admin panel.
|
||||
export function ipTrialsLifetime(ip) {
|
||||
const key = ipCapKey(ip);
|
||||
if (!key) return 0;
|
||||
// IPv4 → exact match. IPv6 → prefix LIKE match (key ends with ":",
|
||||
// so the SQL LIKE 'key%' walks every full IPv6 in that /64 prefix).
|
||||
// Both are index-friendly when ip_address is indexed.
|
||||
if (key.includes(":")) {
|
||||
const row = getDb()
|
||||
.prepare("SELECT COUNT(*) AS n FROM anon_trials WHERE ip_address LIKE ?")
|
||||
.get(key + "%");
|
||||
return row?.n || 0;
|
||||
}
|
||||
const row = getDb()
|
||||
.prepare("SELECT COUNT(*) AS n FROM anon_trials WHERE ip_address = ?")
|
||||
.get(key);
|
||||
return row?.n || 0;
|
||||
}
|
||||
|
||||
// Insert a new trial row and return it. Caller is responsible for
|
||||
// setting the Set-Cookie header on the response.
|
||||
function createTrial({ ip, ua, creditsTotal }) {
|
||||
const cookieId = randomBytes(32).toString("base64url");
|
||||
const now = Date.now();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO anon_trials
|
||||
(cookie_id, ip_address, user_agent, created_at, credits_total, credits_used)
|
||||
VALUES (?, ?, ?, ?, ?, 0)`,
|
||||
)
|
||||
.run(cookieId, ip || null, clipUA(ua), now, creditsTotal);
|
||||
return {
|
||||
cookie_id: cookieId,
|
||||
ip_address: ip || null,
|
||||
user_agent: clipUA(ua),
|
||||
created_at: now,
|
||||
credits_total: creditsTotal,
|
||||
credits_used: 0,
|
||||
last_used_at: null,
|
||||
converted_to_user_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
// lookupTrial(cookieId) — returns the row or null. Doesn't check
|
||||
// credit balance; that's the caller's job.
|
||||
export function lookupTrial(cookieId) {
|
||||
if (!cookieId) return null;
|
||||
return (
|
||||
getDb()
|
||||
.prepare("SELECT * FROM anon_trials WHERE cookie_id = ?")
|
||||
.get(cookieId) || null
|
||||
);
|
||||
}
|
||||
|
||||
// hasTrialBudget(trial) — true iff the trial row exists and has
|
||||
// unused credits. Centralized so the policy is one place.
|
||||
export function hasTrialBudget(trial) {
|
||||
if (!trial) return false;
|
||||
return trial.credits_used < trial.credits_total;
|
||||
}
|
||||
|
||||
// issueIfEligible({ req, res, forceMint }) — atomic "do we issue a
|
||||
// trial cookie to this request?" decision. Called by the auth
|
||||
// middleware when no session cookie is present AND the request is
|
||||
// hitting a path that counts as "actually using the product" (e.g.
|
||||
// POST /api/process).
|
||||
//
|
||||
// Returns the trial row on success, or null if:
|
||||
// - trial_credits_per_visitor is 0 (trials disabled) AND
|
||||
// forceMint is false
|
||||
// - the IP has already exhausted its lifetime mint quota AND
|
||||
// forceMint is false
|
||||
// - a DB error occurred (logged, fail-closed to "no trial")
|
||||
//
|
||||
// forceMint: caller asserts this isn't a free-trial-abuse scenario
|
||||
// (e.g., the visitor is paying for credits — they need a tracking
|
||||
// cookie regardless of trial policy). When set:
|
||||
// - IP lifetime cap is bypassed
|
||||
// - Trials-disabled config is bypassed, but the minted cookie
|
||||
// gets credits_total = 0 (no free bonus on a trials-off install)
|
||||
// Normal /api/process traffic should NEVER set this; only paid-flow
|
||||
// callers (/api/credits/buy) where "no cookie → can't credit the
|
||||
// settle" is a worse failure than enforcing the free-trial cap.
|
||||
//
|
||||
// On success, also writes the Set-Cookie header so the browser
|
||||
// carries the trial id on subsequent requests.
|
||||
export async function issueIfEligible({ req, res, forceMint = false }) {
|
||||
let cfg;
|
||||
try {
|
||||
cfg = await getTrialConfig();
|
||||
} catch (err) {
|
||||
console.warn("[anon-trial] config read failed:", err);
|
||||
return null;
|
||||
}
|
||||
// Decide the credits_total this trial row starts with. Paying
|
||||
// buyers on a trials-off install still get a cookie — credits_total
|
||||
// is just 0 — so /api/credits/buy has somewhere to land the purchase.
|
||||
const creditsTotal = forceMint
|
||||
? Math.max(0, cfg.creditsPerVisitor)
|
||||
: cfg.creditsPerVisitor;
|
||||
if (!forceMint && creditsTotal <= 0) return null; // trials disabled
|
||||
|
||||
const ip = getClientIp(req);
|
||||
if (!forceMint && ip && ipTrialsLifetime(ip) >= cfg.perIpLifetime) {
|
||||
// Over the lifetime IP cap. Don't issue; visitor sees the same
|
||||
// "sign up" nudge a returning trial-exhausted user sees. The
|
||||
// operator can grep magic_link_tokens + anon_trials by ip_address
|
||||
// if a pattern emerges, or manually grant credits to specific
|
||||
// tenants from the admin panel.
|
||||
return null;
|
||||
}
|
||||
|
||||
let trial;
|
||||
try {
|
||||
trial = createTrial({
|
||||
ip,
|
||||
ua: req.headers?.["user-agent"] || "",
|
||||
creditsTotal,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[anon-trial] createTrial failed:", err);
|
||||
return null;
|
||||
}
|
||||
// Diagnostic: log the resolved IP + the cap-counting key so an
|
||||
// operator can grep mint events to verify the IP detector is
|
||||
// working (a flood of mints with ip=null or ip=127.0.0.1 means
|
||||
// the reverse-proxy isn't passing X-Forwarded-For). Cap key
|
||||
// shows the bucket this mint counted under — for IPv6 this is
|
||||
// the /64 prefix the mint will share with future addresses on
|
||||
// the same network.
|
||||
console.log(
|
||||
`[anon-trial] minted cookie for ip=${ip || "(unknown)"} cap_key=${ipCapKey(ip) || "(none)"} credits=${creditsTotal}`,
|
||||
);
|
||||
|
||||
// Set-Cookie. HttpOnly so browser-side JS can't read or replay it;
|
||||
// SameSite=Lax is enough since we never need cross-site trial
|
||||
// posts. Secure is on in production (StartOS terminates HTTPS at
|
||||
// the tunnel) but harmless on localhost dev.
|
||||
const maxAgeSeconds = Math.floor(TRIAL_COOKIE_MAX_AGE_MS / 1000);
|
||||
res.setHeader?.(
|
||||
"Set-Cookie",
|
||||
[
|
||||
`${TRIAL_COOKIE}=${trial.cookie_id}`,
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
"Secure",
|
||||
].join("; "),
|
||||
);
|
||||
return trial;
|
||||
}
|
||||
|
||||
// debitOne(cookieId) — atomic +1 to credits_used. Returns the new
|
||||
// row. Caller (the /api/process handler in multi-mode) calls this
|
||||
// AFTER the relay accepts the request, so a failed relay call
|
||||
// doesn't burn a trial credit.
|
||||
export function debitOne(cookieId) {
|
||||
const now = Date.now();
|
||||
getDb()
|
||||
.prepare(
|
||||
"UPDATE anon_trials SET credits_used = credits_used + 1, last_used_at = ? WHERE cookie_id = ?",
|
||||
)
|
||||
.run(now, cookieId);
|
||||
return lookupTrial(cookieId);
|
||||
}
|
||||
|
||||
// linkToUser(cookieId, userId) — called by /auth/verify when a trial
|
||||
// holder completes signup. Records the conversion for analytics AND
|
||||
// transfers any unused credits on the trial to the user's
|
||||
// tenant_credits balance. This is the user-facing promise: "your free
|
||||
// trial credits + any credits you bought during the trial transfer
|
||||
// to your account when you sign up."
|
||||
//
|
||||
// "Unused" = credits_total - credits_used. The default trial allowance
|
||||
// (e.g. 1 free) plus any credits the visitor purchased anonymously
|
||||
// minus whatever they've already spent.
|
||||
//
|
||||
// Returns the number of credits transferred (0 if none).
|
||||
//
|
||||
// Async because we opportunistically sweep any settled-but-unapplied
|
||||
// pending purchases for this anon cookie FIRST, so a la carte credits
|
||||
// the visitor bought right before signup (and where the BTCPay
|
||||
// redirect killed the frontend poller) get rolled into credits_total
|
||||
// before we compute the transfer. Without this sweep, a buy-then-
|
||||
// immediately-sign-up flow drops the just-purchased credits on the
|
||||
// floor.
|
||||
export async function linkToUser(cookieId, userId) {
|
||||
if (!cookieId || !userId) return 0;
|
||||
try {
|
||||
const { sweepUnappliedPurchases } = await import("./credits-purchase.js");
|
||||
await sweepUnappliedPurchases({ buyerType: "anon", buyerId: cookieId });
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[anon-trial] pre-link sweep failed for ${cookieId}: ${err?.message || err}`,
|
||||
);
|
||||
}
|
||||
const db = getDb();
|
||||
const trial = db
|
||||
.prepare(
|
||||
"SELECT credits_total, credits_used FROM anon_trials WHERE cookie_id = ?",
|
||||
)
|
||||
.get(cookieId);
|
||||
const remaining = trial
|
||||
? Math.max(
|
||||
0,
|
||||
(trial.credits_total || 0) - (trial.credits_used || 0),
|
||||
)
|
||||
: 0;
|
||||
|
||||
// Carry-over credits go into the PURCHASED bucket — they're a mix
|
||||
// of "leftover free trial allowance" + "credits the anon bought a
|
||||
// la carte". Treating all of them as purchased (permanent) is the
|
||||
// safe interpretation; the user paid for some of these and the
|
||||
// others were earned by clicking through a signup. We don't want
|
||||
// the next replenishment cycle wiping them.
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(
|
||||
"UPDATE anon_trials SET converted_to_user_id = ? WHERE cookie_id = ?",
|
||||
).run(userId, cookieId);
|
||||
if (remaining > 0) {
|
||||
const existing = db
|
||||
.prepare("SELECT user_id FROM tenant_credits WHERE user_id = ?")
|
||||
.get(userId);
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE tenant_credits
|
||||
SET purchased_balance = purchased_balance + ?,
|
||||
lifetime_granted = lifetime_granted + ?
|
||||
WHERE user_id = ?`,
|
||||
).run(remaining, remaining, userId);
|
||||
} else {
|
||||
db.prepare(
|
||||
`INSERT INTO tenant_credits
|
||||
(user_id, purchased_balance, replenish_balance, last_replenish_at,
|
||||
lifetime_granted, lifetime_consumed)
|
||||
VALUES (?, ?, 0, ?, ?, 0)`,
|
||||
).run(userId, remaining, Date.now(), remaining);
|
||||
}
|
||||
}
|
||||
});
|
||||
tx();
|
||||
if (remaining > 0) {
|
||||
console.log(
|
||||
`[anon-trial] transferred ${remaining} unused credits from ${cookieId} → user ${userId}`,
|
||||
);
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
+12
-1
@@ -53,7 +53,18 @@ export async function splitAudioFile(inputPath, outputDir, chunkSeconds = 2700)
|
||||
"-acodec", "copy",
|
||||
chunkPath,
|
||||
], { timeout: 120000 });
|
||||
chunks.push({ path: chunkPath, startOffset: startSec, index: i });
|
||||
chunks.push({
|
||||
path: chunkPath,
|
||||
startOffset: startSec,
|
||||
// Actual seconds in THIS chunk (the last chunk is usually
|
||||
// shorter than chunkSeconds). Carried downstream so the
|
||||
// transcribe-stitching code can sanity-cap timestamps each
|
||||
// chunk's model emits — some models hallucinate offsets
|
||||
// way past the chunk's audio (observed: gemini-3.1-flash-lite
|
||||
// emitting [10:12:44] on a 45-min chunk).
|
||||
durationSec: segLen,
|
||||
index: i,
|
||||
});
|
||||
startSec += chunkSeconds;
|
||||
i++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,814 @@
|
||||
// Magic-link auth endpoints for multi-tenant cloud mode.
|
||||
//
|
||||
// Flow:
|
||||
// 1. POST /auth/request-link { email }
|
||||
// - Normalize, rate-limit, generate token, hash, store hash
|
||||
// - Send email with verifyUrl containing the plaintext token
|
||||
// - Always returns { ok: true } — never leaks whether email exists
|
||||
//
|
||||
// 2. GET /auth/verify?token=<plaintext>
|
||||
// - Hash, look up, validate (unused + unexpired)
|
||||
// - Mark used; upsert user; issue session cookie
|
||||
// - If first user ever, mark is_admin = 1 (operator bootstrap)
|
||||
// - If request carries a recap_anon_trial cookie, link the trial
|
||||
// row to the new user_id so their trial summary lands in their
|
||||
// library and the conversion gets recorded
|
||||
// - 302 redirect to /
|
||||
//
|
||||
// 3. POST /auth/signout
|
||||
// - Delete session row, clear cookie, redirect to / (or 204 if API)
|
||||
//
|
||||
// Multi-mode only. The route registration helper itself returns early
|
||||
// in single mode so single-mode boot doesn't initialize SMTP / DB
|
||||
// codepaths.
|
||||
|
||||
import {
|
||||
randomBytes,
|
||||
createHash,
|
||||
scryptSync,
|
||||
timingSafeEqual,
|
||||
} from "crypto";
|
||||
import * as cookie from "cookie";
|
||||
import { getDb } from "./db.js";
|
||||
import { sendMail, isSmtpReady } from "./smtp.js";
|
||||
import { renderMagicLinkEmail } from "./email-template.js";
|
||||
import { getConfigSnapshot } from "./config.js";
|
||||
import { getClientIp, TRIAL_COOKIE, linkToUser } from "./anon-trial.js";
|
||||
import { renameScopeDir } from "./history.js";
|
||||
import { requireUser } from "./tenant-auth.js";
|
||||
|
||||
export const SESSION_COOKIE = "recap_session";
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
// ── Password hashing ─────────────────────────────────────────────────
|
||||
// scrypt with a per-password 16-byte salt, stored as
|
||||
// "scrypt$<saltHex>$<hashHex>" in users.password_hash. Same primitive
|
||||
// admin-auth.js uses for the single-mode operator password, so the
|
||||
// crypto surface area stays consistent across the codebase. N=2^15
|
||||
// (KDF cost) is what the Node docs suggest for interactive logins —
|
||||
// ~50ms on commodity hardware, slow enough to deter brute force,
|
||||
// fast enough to not feel laggy.
|
||||
const SCRYPT_KEYLEN = 64;
|
||||
const SCRYPT_OPTS = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
||||
const SCRYPT_PREFIX = "scrypt$";
|
||||
|
||||
export function hashPassword(plain) {
|
||||
const salt = randomBytes(16);
|
||||
const derived = scryptSync(plain, salt, SCRYPT_KEYLEN, SCRYPT_OPTS);
|
||||
return `${SCRYPT_PREFIX}${salt.toString("hex")}$${derived.toString("hex")}`;
|
||||
}
|
||||
|
||||
function verifyPassword(plain, stored) {
|
||||
if (!stored || !stored.startsWith(SCRYPT_PREFIX)) return false;
|
||||
const [, saltHex, hashHex] = stored.split("$");
|
||||
if (!saltHex || !hashHex) return false;
|
||||
let salt, expected;
|
||||
try {
|
||||
salt = Buffer.from(saltHex, "hex");
|
||||
expected = Buffer.from(hashHex, "hex");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (expected.length !== SCRYPT_KEYLEN) return false;
|
||||
let actual;
|
||||
try {
|
||||
actual = scryptSync(plain, salt, SCRYPT_KEYLEN, SCRYPT_OPTS);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// timingSafeEqual requires equal length, which we just enforced above.
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
// Mild policy — 8 char minimum, 256 max. We deliberately don't enforce
|
||||
// "one uppercase + one digit + ..." style rules; the consensus modern
|
||||
// view (NIST 800-63B) is that length matters far more than composition,
|
||||
// and rules that demand specific shapes just push users toward
|
||||
// predictable substitutions ("Password1!"). Min 8 catches the worst
|
||||
// cases without alienating people using passphrases.
|
||||
export function validatePasswordPolicy(plain) {
|
||||
if (typeof plain !== "string") return "password_required";
|
||||
if (plain.length < 8) return "password_too_short";
|
||||
if (plain.length > 256) return "password_too_long";
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Rate limits ────────────────────────────────────────────────────────
|
||||
// Both buckets are in-memory: not durable across restarts, but the worst
|
||||
// case is one extra link request per email/IP per restart, which is fine.
|
||||
// For abuse on the scale where in-memory limits are insufficient, the
|
||||
// operator's IP/UA logs + manual deny-list are the next layer.
|
||||
const MAX_LINKS_PER_EMAIL_PER_HOUR = 5;
|
||||
const MAX_LINKS_PER_IP_PER_HOUR = 10;
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
const emailBuckets = new Map(); // email → [timestamp, ...]
|
||||
const ipBuckets = new Map(); // ip → [timestamp, ...]
|
||||
|
||||
function pushBucket(map, key, now) {
|
||||
const arr = map.get(key) || [];
|
||||
const fresh = arr.filter((t) => now - t < ONE_HOUR_MS);
|
||||
fresh.push(now);
|
||||
map.set(key, fresh);
|
||||
return fresh.length;
|
||||
}
|
||||
|
||||
function bucketCount(map, key, now) {
|
||||
const arr = map.get(key) || [];
|
||||
const fresh = arr.filter((t) => now - t < ONE_HOUR_MS);
|
||||
if (fresh.length !== arr.length) map.set(key, fresh);
|
||||
return fresh.length;
|
||||
}
|
||||
|
||||
// Periodically prune old bucket entries so the maps don't grow
|
||||
// unbounded under heavy traffic. Fire-and-forget; the filter above
|
||||
// also self-prunes per access.
|
||||
const BUCKET_GC_MS = 5 * 60 * 1000;
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - ONE_HOUR_MS;
|
||||
for (const map of [emailBuckets, ipBuckets]) {
|
||||
for (const [key, arr] of map.entries()) {
|
||||
const fresh = arr.filter((t) => t > cutoff);
|
||||
if (fresh.length === 0) map.delete(key);
|
||||
else if (fresh.length !== arr.length) map.set(key, fresh);
|
||||
}
|
||||
}
|
||||
}, BUCKET_GC_MS).unref?.();
|
||||
|
||||
function normalizeEmail(raw) {
|
||||
if (typeof raw !== "string") return "";
|
||||
return raw.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isPlausibleEmail(s) {
|
||||
// Deliberately permissive — the user's mail server is the source of
|
||||
// truth for whether an address works (they either receive the link or
|
||||
// they don't). Just sanity-check that we have something with @ and a
|
||||
// dot in the domain.
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) && s.length <= 254;
|
||||
}
|
||||
|
||||
function sha256(s) {
|
||||
return createHash("sha256").update(s).digest("hex");
|
||||
}
|
||||
|
||||
function uuid() {
|
||||
// 16 random bytes formatted as a UUIDv4-ish hex with hyphens. Doesn't
|
||||
// need to be RFC-compliant — uniqueness is the only requirement.
|
||||
return randomBytes(16).toString("hex");
|
||||
}
|
||||
|
||||
function clipUA(ua) {
|
||||
return String(ua || "").slice(0, 256);
|
||||
}
|
||||
|
||||
// ── Public URL plumbing ────────────────────────────────────────────────
|
||||
// The verify URL in the email needs the operator's ClearNet URL set via
|
||||
// the "Set Recap Public URL" StartOS action. We refuse to send magic
|
||||
// links if it's empty — otherwise the email would link to localhost or
|
||||
// an internal hostname.
|
||||
async function getPublicUrl() {
|
||||
const snap = await getConfigSnapshot();
|
||||
const url = (snap.recap_public_url || "").trim();
|
||||
return url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
// ── Route handlers ─────────────────────────────────────────────────────
|
||||
|
||||
async function handleRequestLink(req, res) {
|
||||
const email = normalizeEmail(req.body?.email);
|
||||
if (!email || !isPlausibleEmail(email)) {
|
||||
// Deliberately vague — don't help enumerate valid emails. Just say
|
||||
// "ok" even on bad input so the response shape is the same.
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
const publicUrl = await getPublicUrl();
|
||||
if (!publicUrl) {
|
||||
console.error(
|
||||
"[auth] /auth/request-link blocked: recap_public_url not set. Run the 'Set Recap Public URL' StartOS action first.",
|
||||
);
|
||||
return res.status(503).json({
|
||||
error: "public_url_not_set",
|
||||
message:
|
||||
"This Recap instance hasn't been fully configured yet. Ask the operator to set the public URL.",
|
||||
});
|
||||
}
|
||||
if (!isSmtpReady()) {
|
||||
console.error(
|
||||
"[auth] /auth/request-link blocked: SMTP not ready. Configure StartOS System SMTP.",
|
||||
);
|
||||
return res.status(503).json({
|
||||
error: "smtp_not_ready",
|
||||
message:
|
||||
"This Recap instance can't send email yet. Ask the operator to configure SMTP.",
|
||||
});
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const now = Date.now();
|
||||
|
||||
// Rate limits. Two buckets — email and IP. Either trips → 429 with a
|
||||
// generic message that doesn't leak which limit was hit.
|
||||
if (bucketCount(emailBuckets, email, now) >= MAX_LINKS_PER_EMAIL_PER_HOUR) {
|
||||
return res.status(429).json({
|
||||
error: "rate_limited",
|
||||
message: "Too many sign-in requests for this email. Try again in an hour.",
|
||||
});
|
||||
}
|
||||
if (ip && bucketCount(ipBuckets, ip, now) >= MAX_LINKS_PER_IP_PER_HOUR) {
|
||||
return res.status(429).json({
|
||||
error: "rate_limited",
|
||||
message: "Too many sign-in requests from this network. Try again in an hour.",
|
||||
});
|
||||
}
|
||||
|
||||
pushBucket(emailBuckets, email, now);
|
||||
if (ip) pushBucket(ipBuckets, ip, now);
|
||||
|
||||
// Determine intent: signin (existing user) vs signup (new email).
|
||||
// Both flows are identical to the server — the field is just for
|
||||
// analytics. We don't leak intent in the response.
|
||||
const existing = getDb()
|
||||
.prepare("SELECT id FROM users WHERE email = ?")
|
||||
.get(email);
|
||||
const intent = existing ? "signin" : "signup";
|
||||
|
||||
// Capture the trial cookie at request-link time and store it
|
||||
// server-side alongside the token. At /auth/verify we'll use it
|
||||
// to link the trial → user even when the magic-link click lands
|
||||
// in a different browser / cookie jar than the one that requested
|
||||
// the link (the iOS Private mode + in-app email webview case).
|
||||
// We read it directly from req.headers.cookie because the trial
|
||||
// cookie middleware may or may not have populated req.trial
|
||||
// depending on path matching — being explicit here is safer.
|
||||
let trialCookieId = null;
|
||||
try {
|
||||
const parsed = cookie.parse(req.headers?.cookie || "");
|
||||
if (parsed[TRIAL_COOKIE]) trialCookieId = parsed[TRIAL_COOKIE];
|
||||
} catch {
|
||||
// cookie parse failures are non-fatal; fall through with null
|
||||
}
|
||||
|
||||
// Issue + send via the shared helper. We deliberately don't
|
||||
// surface send failures back to the user — the standard advice for
|
||||
// magic-link auth is "always pretend we sent it" so attackers can't
|
||||
// probe which emails are configured. Operator sees the error in
|
||||
// logs and can investigate (usually SMTP creds wrong or Gmail
|
||||
// rate-limited).
|
||||
await sendSignInLink({
|
||||
email,
|
||||
intent,
|
||||
ip,
|
||||
userAgent: req.headers?.["user-agent"],
|
||||
trialCookieId,
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
async function handleVerify(req, res) {
|
||||
const plaintext = String(req.query?.token || "").trim();
|
||||
if (!plaintext) {
|
||||
return res.status(400).send(renderErrorPage("Missing token."));
|
||||
}
|
||||
|
||||
const tokenHash = sha256(plaintext);
|
||||
const now = Date.now();
|
||||
|
||||
const tx = getDb().transaction(() => {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"SELECT * FROM magic_link_tokens WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?",
|
||||
)
|
||||
.get(tokenHash, now);
|
||||
if (!row) return { error: "invalid_or_expired" };
|
||||
getDb()
|
||||
.prepare("UPDATE magic_link_tokens SET used_at = ? WHERE token_hash = ?")
|
||||
.run(now, tokenHash);
|
||||
return { row };
|
||||
});
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = tx();
|
||||
} catch (err) {
|
||||
console.error("[auth] verify tx failed:", err);
|
||||
return res.status(500).send(renderErrorPage("Internal error."));
|
||||
}
|
||||
if (result.error) {
|
||||
return res
|
||||
.status(400)
|
||||
.send(
|
||||
renderErrorPage(
|
||||
"This sign-in link has expired or already been used. Request a fresh one.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const email = result.row.email;
|
||||
const ip = getClientIp(req);
|
||||
const ua = clipUA(req.headers?.["user-agent"]);
|
||||
|
||||
// Upsert user row.
|
||||
let user = getDb()
|
||||
.prepare("SELECT * FROM users WHERE email = ?")
|
||||
.get(email);
|
||||
if (!user) {
|
||||
const id = uuid();
|
||||
const syntheticInstallId = uuid();
|
||||
// First user ever = operator bootstrap. We could also gate this on
|
||||
// "is the install fresh (no users at all)" — which is what this
|
||||
// check does. Once at least one user exists, subsequent signups
|
||||
// are regular tenants with is_admin = 0.
|
||||
const userCountRow = getDb()
|
||||
.prepare("SELECT COUNT(*) AS n FROM users")
|
||||
.get();
|
||||
const isAdmin = (userCountRow?.n || 0) === 0 ? 1 : 0;
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO users
|
||||
(id, email, created_at, last_signin_at, synthetic_install_id, is_admin, signup_ip, signup_user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(id, email, now, now, syntheticInstallId, isAdmin, ip || null, ua);
|
||||
user = getDb().prepare("SELECT * FROM users WHERE id = ?").get(id);
|
||||
|
||||
// Seed tenant_credits for new NON-ADMIN users with the operator's
|
||||
// configured default. Goes into the REPLENISHABLE bucket so
|
||||
// setReplenishPeriod=daily/weekly/monthly refills it on schedule.
|
||||
// Admin users skip this entirely — their relay calls bill the
|
||||
// operator pool (via /data/license.txt), not a local balance.
|
||||
//
|
||||
// Pro/Max users (license attached at creation, e.g. via the
|
||||
// anon-signup-with-purchase flow) ALSO skip this — they spend
|
||||
// from their license-keyed relay pool, so a tenant_credits row
|
||||
// would just sit unused and confuse the admin Tenants view.
|
||||
if (!isAdmin && !user.keysat_license) {
|
||||
try {
|
||||
const { seedSignup } = await import("./tenant-credits.js");
|
||||
await seedSignup(id);
|
||||
} catch (err) {
|
||||
console.warn("[auth] tenant_credits seed failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
console.log(
|
||||
`[auth] First user signed up — ${email} bootstrapped as operator (is_admin=1)`,
|
||||
);
|
||||
// Legacy library at /data/history/owner/ stays where it is —
|
||||
// admin's scopeForRequest() returns "owner" regardless of mode,
|
||||
// so single-mode and multi-mode admin both read the same path.
|
||||
// No rename. Switching back to single mode keeps the operator's
|
||||
// library accessible at /data/history/owner/.
|
||||
}
|
||||
} else {
|
||||
getDb()
|
||||
.prepare("UPDATE users SET last_signin_at = ? WHERE id = ?")
|
||||
.run(now, user.id);
|
||||
}
|
||||
|
||||
// Resolve which anon trial cookie to link. Two sources:
|
||||
// 1. result.row.trial_cookie_id — captured server-side at
|
||||
// /auth/request-link time. Survives cross-browser / in-app-
|
||||
// webview magic-link clicks because it doesn't depend on the
|
||||
// verify request carrying the cookie.
|
||||
// 2. req.cookies[TRIAL_COOKIE] — the legacy path, used when the
|
||||
// magic-link click lands in the SAME browser that did the
|
||||
// anon activity (typical desktop / non-private-mode flow).
|
||||
//
|
||||
// Priority: token-row wins. It represents the explicit intent
|
||||
// captured at signup-request time and is the more reliable
|
||||
// signal. The req cookie is a fallback for old token rows from
|
||||
// before the trial_cookie_id column existed, and for any
|
||||
// edge-case where the request-link path didn't capture it.
|
||||
//
|
||||
// We also rename the trial's history folder (anon/<cookie_id>/) to
|
||||
// the user's id so the summary they ran before signing up shows
|
||||
// up in their library. Skipped for returning users (existing
|
||||
// account) — they already have a library and we can't safely
|
||||
// merge filesystem-side. The trial row still gets linked; the
|
||||
// anon/<cookie_id>/ folder just stays as an orphan.
|
||||
try {
|
||||
let trialCookieId = result.row.trial_cookie_id || null;
|
||||
if (!trialCookieId) {
|
||||
const cookies = cookie.parse(req.headers?.cookie || "");
|
||||
trialCookieId = cookies[TRIAL_COOKIE] || null;
|
||||
}
|
||||
if (trialCookieId) {
|
||||
await linkToUser(trialCookieId, user.id);
|
||||
if (user.created_at === user.last_signin_at) {
|
||||
try {
|
||||
await renameScopeDir(`anon/${trialCookieId}`, user.id);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[auth] trial→user scope rename failed:",
|
||||
err?.message || err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort; trial linking isn't on the critical signin path
|
||||
}
|
||||
|
||||
// Issue session — shared with /auth/signin-password so cookie shape
|
||||
// + sessions-row format stay identical regardless of which auth
|
||||
// method got the user here.
|
||||
issueSession({ userId: user.id, req, res });
|
||||
res.redirect(302, "/");
|
||||
}
|
||||
|
||||
// Shared session-issuance helper. Used by both /auth/verify (magic-link
|
||||
// success path) and /auth/signin-password (password success path) so
|
||||
// the cookie shape + sessions-row format stay identical.
|
||||
function issueSession({ userId, req, res }) {
|
||||
const sessionId = randomBytes(32).toString("base64url");
|
||||
const now = Date.now();
|
||||
const expiresAt = now + SESSION_MAX_AGE_MS;
|
||||
const ua = clipUA(req.headers?.["user-agent"]);
|
||||
const ip = getClientIp(req);
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO sessions
|
||||
(id, user_id, created_at, expires_at, last_used_at, user_agent, ip_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(sessionId, userId, now, expiresAt, now, ua, ip || null);
|
||||
const maxAgeSeconds = Math.floor(SESSION_MAX_AGE_MS / 1000);
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
[
|
||||
`${SESSION_COOKIE}=${sessionId}`,
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
"Secure",
|
||||
].join("; "),
|
||||
);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
// ── Internal sessions for the subscription background processor ──────────────
|
||||
// Per-tenant subscriptions: the processor summarizes an approved auto-queue
|
||||
// item by calling /api/process over loopback, which in multi mode is
|
||||
// authenticated. So it mints a SHORT-LIVED real session for the item's
|
||||
// owning user, sends it as the recap_session cookie, and deletes it right
|
||||
// after. This reuses the real auth path — NOT a bypass: a bad/expired token
|
||||
// just 401s and the item is marked failed, never an auth hole.
|
||||
export function mintInternalSession(userId) {
|
||||
const sessionId = randomBytes(32).toString("base64url");
|
||||
const now = Date.now();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO sessions
|
||||
(id, user_id, created_at, expires_at, last_used_at, user_agent, ip_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
sessionId,
|
||||
userId,
|
||||
now,
|
||||
now + 30 * 60 * 1000, // 30 min is plenty for one summarize run
|
||||
now,
|
||||
"subscription-processor",
|
||||
"127.0.0.1",
|
||||
);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
export function deleteInternalSession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
getDb().prepare("DELETE FROM sessions WHERE id = ?").run(sessionId);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// The operator/admin owns the "owner" scope in multi mode — resolve their
|
||||
// user id so the processor can run owner-scoped items as the operator.
|
||||
export function adminUserId() {
|
||||
try {
|
||||
const row = getDb()
|
||||
.prepare("SELECT id FROM users WHERE is_admin = 1 ORDER BY created_at LIMIT 1")
|
||||
.get();
|
||||
return row?.id || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// /auth/signin-password — accept { email, password }, verify, issue a
|
||||
// session cookie. Used by the auth.html form when the user opts to
|
||||
// type a password (faster than waiting on an email).
|
||||
//
|
||||
// We DELIBERATELY don't differentiate "no such email" vs "wrong
|
||||
// password" in the error response — both return 401 with a generic
|
||||
// message. This stops credential-stuffing tools from using us as an
|
||||
// email-existence oracle.
|
||||
//
|
||||
// Rate limits: same email + IP buckets as /auth/request-link, so the
|
||||
// password endpoint can't be brute-forced any faster than someone
|
||||
// could spam magic-link emails.
|
||||
async function handleSignInPassword(req, res) {
|
||||
const email = normalizeEmail(req.body?.email);
|
||||
const password = req.body?.password;
|
||||
if (!email || !isPlausibleEmail(email) || typeof password !== "string") {
|
||||
return res.status(401).json({
|
||||
error: "bad_credentials",
|
||||
message: "Email or password is wrong.",
|
||||
});
|
||||
}
|
||||
const ip = getClientIp(req);
|
||||
const now = Date.now();
|
||||
if (bucketCount(emailBuckets, email, now) >= MAX_LINKS_PER_EMAIL_PER_HOUR) {
|
||||
return res
|
||||
.status(429)
|
||||
.json({ error: "rate_limited", message: "Too many sign-in attempts." });
|
||||
}
|
||||
if (ip && bucketCount(ipBuckets, ip, now) >= MAX_LINKS_PER_IP_PER_HOUR) {
|
||||
return res
|
||||
.status(429)
|
||||
.json({ error: "rate_limited", message: "Too many sign-in attempts." });
|
||||
}
|
||||
pushBucket(emailBuckets, email, now);
|
||||
if (ip) pushBucket(ipBuckets, ip, now);
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = getDb()
|
||||
.prepare("SELECT * FROM users WHERE email = ?")
|
||||
.get(email);
|
||||
} catch (err) {
|
||||
console.error("[auth] signin-password lookup failed:", err);
|
||||
return res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
// Run scrypt even when the user doesn't exist so timing doesn't
|
||||
// betray which emails are registered. The dummy verify spends the
|
||||
// same ~50ms scrypt-or-so as the real path.
|
||||
const stored = user?.password_hash || `${SCRYPT_PREFIX}0000$0000`;
|
||||
const ok = verifyPassword(password, stored);
|
||||
if (!user || !user.password_hash || !ok) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "bad_credentials", message: "Email or password is wrong." });
|
||||
}
|
||||
// Authenticated. Update last_signin_at and issue a session.
|
||||
getDb()
|
||||
.prepare("UPDATE users SET last_signin_at = ? WHERE id = ?")
|
||||
.run(now, user.id);
|
||||
issueSession({ userId: user.id, req, res });
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
async function handleSignout(req, res) {
|
||||
try {
|
||||
const cookies = cookie.parse(req.headers?.cookie || "");
|
||||
const sessionId = cookies[SESSION_COOKIE];
|
||||
if (sessionId) {
|
||||
getDb()
|
||||
.prepare("DELETE FROM sessions WHERE id = ?")
|
||||
.run(sessionId);
|
||||
}
|
||||
} catch {}
|
||||
// Expire the cookie immediately.
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
`${SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure`,
|
||||
);
|
||||
// Honor JSON callers (UI fetch) vs link callers (form submit).
|
||||
if (req.accepts?.("json") && !req.accepts?.("html")) {
|
||||
return res.status(204).end();
|
||||
}
|
||||
res.redirect(302, "/");
|
||||
}
|
||||
|
||||
// Self-contained branded error page used when /auth/verify fails.
|
||||
// Doesn't pull in the full app shell so it works even if the static
|
||||
// bundle is broken or partially served. Visual style matches /auth.html
|
||||
// (same dark-glass card, same primary-button accent) so the user feels
|
||||
// they're still in the same product flow.
|
||||
function renderErrorPage(message) {
|
||||
const safe = String(message)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Sign-in failed · Recaps</title>
|
||||
<link rel="icon" type="image/png" href="/assets/icon.png">
|
||||
<meta name="theme-color" content="#0a0e1a">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
||||
background:#0a0e1a;color:#e2e8f0;
|
||||
display:flex;align-items:center;justify-content:center;padding:24px;
|
||||
}
|
||||
.card{
|
||||
width:100%;max-width:420px;background:#121828;
|
||||
border:1px solid #1f2942;border-radius:12px;padding:32px 28px;
|
||||
}
|
||||
.logo{display:flex;align-items:center;gap:12px;margin-bottom:24px}
|
||||
.logo img{width:32px;height:32px;border-radius:6px}
|
||||
.logo span{font-size:18px;font-weight:600;color:#f5f9ff}
|
||||
h1{font-size:20px;font-weight:600;color:#f5f9ff;margin-bottom:10px}
|
||||
p.msg{font-size:14px;line-height:1.55;color:#cbd5e1;margin-bottom:22px}
|
||||
.btn-row{display:flex;gap:8px;flex-wrap:wrap}
|
||||
a.btn-primary{
|
||||
display:inline-block;background:#3b82f6;color:#fff;text-decoration:none;
|
||||
font-size:14px;font-weight:600;padding:10px 18px;border-radius:8px;
|
||||
}
|
||||
a.btn-primary:hover{background:#2563eb}
|
||||
a.btn-secondary{
|
||||
display:inline-block;background:transparent;color:#94a3b8;text-decoration:none;
|
||||
font-size:14px;font-weight:500;padding:10px 14px;border-radius:8px;border:1px solid #334155;
|
||||
}
|
||||
a.btn-secondary:hover{color:#cbd5e1;border-color:#475569}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<img src="/assets/icon.png" alt="Recaps" onerror="this.style.display='none'">
|
||||
<span>Recaps</span>
|
||||
</div>
|
||||
<h1>Sign-in didn't work</h1>
|
||||
<p class="msg">${safe}</p>
|
||||
<div class="btn-row">
|
||||
<a href="/auth.html" class="btn-primary">Get a new sign-in link</a>
|
||||
<a href="/" class="btn-secondary">Back to Recaps</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// sendSignInLink({ email, intent, ip?, userAgent?, emailBody? }) —
|
||||
// reusable magic-link issuance + email send. Used by:
|
||||
// • /auth/request-link — visitor-initiated sign-in
|
||||
// • license-purchase poll-settle — system-initiated post-purchase
|
||||
// "your account is ready" send
|
||||
//
|
||||
// Generates a 32-byte token, hashes it, stores the hash in
|
||||
// magic_link_tokens, builds a verifyUrl, sends the email with either
|
||||
// the default magic-link body OR a caller-supplied (subject, text,
|
||||
// html) tuple for custom flows. Returns { ok: true, expires_at } on
|
||||
// success; { ok: false, error, message? } on failure.
|
||||
//
|
||||
// Doesn't enforce rate limits — that's the caller's job. /auth/request-link
|
||||
// has the per-email + per-IP buckets; the post-purchase path is
|
||||
// inherently rate-limited by the actual payment, so no extra bucket
|
||||
// needed.
|
||||
export async function sendSignInLink({
|
||||
email,
|
||||
intent = "signin",
|
||||
ip = null,
|
||||
userAgent = "",
|
||||
emailBody = null,
|
||||
trialCookieId = null,
|
||||
}) {
|
||||
if (!email || !isPlausibleEmail(email)) {
|
||||
return { ok: false, error: "bad_email" };
|
||||
}
|
||||
const publicUrl = await getPublicUrl();
|
||||
if (!publicUrl) {
|
||||
return { ok: false, error: "public_url_not_set" };
|
||||
}
|
||||
if (!isSmtpReady()) {
|
||||
return { ok: false, error: "smtp_not_ready" };
|
||||
}
|
||||
const now = Date.now();
|
||||
const plaintext = randomBytes(32).toString("base64url");
|
||||
const tokenHash = sha256(plaintext);
|
||||
const expiresAt = now + MAGIC_LINK_TTL_MS;
|
||||
try {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO magic_link_tokens
|
||||
(token_hash, email, created_at, expires_at, intent, request_ip, request_ua, trial_cookie_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
tokenHash,
|
||||
email,
|
||||
now,
|
||||
expiresAt,
|
||||
intent,
|
||||
ip || null,
|
||||
clipUA(userAgent),
|
||||
trialCookieId || null,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[auth] sendSignInLink insert failed:", err);
|
||||
return { ok: false, error: "internal_error" };
|
||||
}
|
||||
const verifyUrl = `${publicUrl}/auth/verify?token=${encodeURIComponent(plaintext)}`;
|
||||
// Default to the standard sign-in email body; callers can override
|
||||
// either with a pre-built {subject,text,html} object OR a function
|
||||
// that receives the verifyUrl and returns that shape. The function
|
||||
// form is what license-purchase uses to inject the celebratory
|
||||
// "your Pro account is ready" copy with the verifyUrl pre-rendered
|
||||
// into the body.
|
||||
let message;
|
||||
if (typeof emailBody === "function") {
|
||||
message = emailBody(verifyUrl);
|
||||
} else if (emailBody && typeof emailBody === "object") {
|
||||
message = emailBody;
|
||||
} else {
|
||||
message = renderMagicLinkEmail({
|
||||
verifyUrl,
|
||||
brandName: "Recaps",
|
||||
expiresInMinutes: 15,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: message.subject,
|
||||
text: message.text,
|
||||
html: message.html,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[auth] sendSignInLink sendMail failed for",
|
||||
email,
|
||||
":",
|
||||
err?.message || err,
|
||||
);
|
||||
// The token is already inserted; if the operator's SMTP is flaky
|
||||
// the user can re-request a link. Return error so the caller can
|
||||
// decide whether to surface it or swallow.
|
||||
return { ok: false, error: "send_failed" };
|
||||
}
|
||||
return { ok: true, expires_at: expiresAt };
|
||||
}
|
||||
|
||||
// setupAuthRoutes(app) — registers /auth/* endpoints. Multi-mode only;
|
||||
// wired in server/index.js behind the RECAP_MODE === 'multi' branch.
|
||||
//
|
||||
// Magic-link is the primary auth surface:
|
||||
// POST /auth/request-link — issue a magic link by email
|
||||
// GET /auth/verify?token=... — consume the link, issue session
|
||||
// POST /auth/signout — drop the session
|
||||
// GET /auth/signout — same (link-click convenience)
|
||||
//
|
||||
// Password endpoints are the optional faster-signin add-on:
|
||||
// POST /auth/set-password — set OR overwrite my password
|
||||
// POST /auth/clear-password — remove my password (magic-link only)
|
||||
// POST /auth/signin-password — sign in with email + password
|
||||
//
|
||||
// Note there is no /auth/reset-password endpoint by design — reset is
|
||||
// implemented as "request a magic link, sign in, then call
|
||||
// /auth/set-password with the new one." Adding a dedicated reset
|
||||
// endpoint would duplicate the magic-link flow without adding any
|
||||
// security or UX.
|
||||
export function setupAuthRoutes(app) {
|
||||
app.post("/auth/request-link", (req, res) => {
|
||||
handleRequestLink(req, res).catch((err) => {
|
||||
console.error("[auth] /auth/request-link unhandled:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/auth/verify", (req, res) => {
|
||||
handleVerify(req, res).catch((err) => {
|
||||
console.error("[auth] /auth/verify unhandled:", err);
|
||||
res.status(500).send(renderErrorPage("Internal error."));
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/auth/signin-password", (req, res) => {
|
||||
handleSignInPassword(req, res).catch((err) => {
|
||||
console.error("[auth] /auth/signin-password unhandled:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
});
|
||||
});
|
||||
|
||||
// Note: /api/account/password (set + clear) is registered by
|
||||
// account-routes.js, not here — those endpoints REQUIRE an existing
|
||||
// session, so they live outside the /auth/* public-path namespace
|
||||
// (which is allowed through the tenant-auth middleware unauthenticated).
|
||||
|
||||
app.post("/auth/signout", (req, res) => {
|
||||
handleSignout(req, res).catch((err) => {
|
||||
console.error("[auth] /auth/signout unhandled:", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
});
|
||||
});
|
||||
|
||||
// Convenience GET version for plain link clicks ("Sign out") from
|
||||
// the UI without needing a form POST.
|
||||
app.get("/auth/signout", (req, res) => {
|
||||
handleSignout(req, res).catch((err) => {
|
||||
console.error("[auth] /auth/signout unhandled:", err);
|
||||
res.status(500).send(renderErrorPage("Internal error."));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Self-serve subscription purchase (multi-mode / cloud only).
|
||||
//
|
||||
// Lets a signed-in cloud user buy their OWN prepaid Pro/Max period
|
||||
// instead of waiting for the operator to grant it by hand. The relay
|
||||
// owns the subscription (keyed by Recaps user-id, per the core-
|
||||
// decoupling); Recaps just brokers the purchase:
|
||||
//
|
||||
// POST /api/billing/buy → ask the relay to mint a BTCPay invoice
|
||||
// for {tier}; return the checkout URL the
|
||||
// frontend opens. On settlement the relay's
|
||||
// webhook extends the user's tier.
|
||||
// GET /api/billing/status → pull the user's current (expiry-enforced)
|
||||
// tier from the relay and refresh the local
|
||||
// users.tier cache so the badge flips the
|
||||
// moment payment lands. The frontend polls
|
||||
// this after opening checkout.
|
||||
//
|
||||
// Auth: both routes require a real signed-in user (req.user.id). Anon /
|
||||
// trial visitors (req.userId = "anon:<cookie>") are refused — a tier is
|
||||
// keyed to a durable user-id, which a trial cookie isn't.
|
||||
//
|
||||
// These live under /api/billing (NOT /api/subscriptions — that prefix is
|
||||
// the channel-subscriptions feature, which is itself Pro-gated; a free
|
||||
// user must be able to reach the buy flow). The prefix is added to the
|
||||
// license middleware's open list so the activation gate lets Core users
|
||||
// through to purchase.
|
||||
|
||||
import { getDb } from "./db.js";
|
||||
import { requireUser } from "./tenant-auth.js";
|
||||
import {
|
||||
createRelayTierInvoice,
|
||||
createRelayZapriteOrder,
|
||||
getRelayUserTier,
|
||||
getRelayTierPlans,
|
||||
} from "./providers/relay.js";
|
||||
|
||||
const BUYABLE_TIERS = new Set(["pro", "max"]);
|
||||
const PAYMENT_METHODS = new Set(["bitcoin", "card"]);
|
||||
|
||||
// Fallback prices (sats / 30-day period) used only when the relay is
|
||||
// unreachable while rendering the picker — matches the relay config
|
||||
// defaults so the UI never shows a blank price. The actual charge is
|
||||
// always computed relay-side at invoice time.
|
||||
const FALLBACK_PLANS = {
|
||||
period_days: 30,
|
||||
plans: [
|
||||
{ tier: "pro", sats: 21000 },
|
||||
{ tier: "max", sats: 42000 },
|
||||
],
|
||||
};
|
||||
|
||||
// Pull the user's effective (expiry-enforced) tier from the relay — the
|
||||
// authoritative subscription owner — and update the cached users.tier if
|
||||
// it drifted. Returns { tier, expires_at, synced } or { synced:false }
|
||||
// when the relay is unreachable / unconfigured (caller falls back to the
|
||||
// cached value rather than erroring the request).
|
||||
export async function syncUserTierFromRelay(userId) {
|
||||
if (!userId) return { synced: false };
|
||||
let report;
|
||||
try {
|
||||
report = await getRelayUserTier({ userId });
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[billing] relay tier read failed for ${userId}: ${err?.message || err}`,
|
||||
);
|
||||
return { synced: false };
|
||||
}
|
||||
// getRelayUserTier swallows errors and returns null when the relay
|
||||
// base URL / operator key isn't configured. Treat that as "can't
|
||||
// sync" rather than "downgrade to core".
|
||||
if (!report || typeof report.tier !== "string") {
|
||||
return { synced: false };
|
||||
}
|
||||
const tier = report.tier; // already expiry-enforced by the relay
|
||||
const expiresAt = report.subscription_expires_at || null;
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = db.prepare("SELECT tier FROM users WHERE id = ?").get(userId);
|
||||
if (row && row.tier !== tier) {
|
||||
db.prepare("UPDATE users SET tier = ? WHERE id = ?").run(tier, userId);
|
||||
console.log(
|
||||
`[billing] synced ${userId} tier ${row.tier || "core"} → ${tier} from relay`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[billing] tier cache update failed for ${userId}: ${err?.message || err}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
tier,
|
||||
expires_at: expiresAt,
|
||||
tier_snapshot: report.tier_snapshot || tier,
|
||||
subscription_expired: !!report.subscription_expired,
|
||||
synced: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Build the buyer-facing origin so the BTCPay checkout can redirect back
|
||||
// to the app after settlement. Honors the reverse-proxy forwarding
|
||||
// headers StartOS sets in front of the service.
|
||||
function originFor(req) {
|
||||
const proto =
|
||||
(req.headers["x-forwarded-proto"] || "").split(",")[0].trim() ||
|
||||
req.protocol ||
|
||||
"https";
|
||||
const host = req.headers["x-forwarded-host"] || req.headers.host || "";
|
||||
return host ? `${proto}://${host}` : "";
|
||||
}
|
||||
|
||||
export function setupBillingRoutes(app) {
|
||||
// GET /api/billing/plans → { period_days, plans: [{tier, sats}] }
|
||||
// Powers the purchase picker. Prices come from the relay (the pricing
|
||||
// source of truth); falls back to the config defaults if the relay is
|
||||
// briefly unreachable so the modal still renders.
|
||||
app.get("/api/billing/plans", requireUser, async (req, res) => {
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(403).json({ error: "must_be_signed_in" });
|
||||
}
|
||||
try {
|
||||
const data = await getRelayTierPlans();
|
||||
if (data && Array.isArray(data.plans) && data.plans.length) {
|
||||
return res.json({
|
||||
period_days: data.period_days || FALLBACK_PLANS.period_days,
|
||||
plans: data.plans,
|
||||
// Whether the card (Zaprite) rail is configured — the UI hides
|
||||
// the "Pay by card" link when false so it never offers a rail
|
||||
// that 503s. Bitcoin is always available (the BTCPay rail).
|
||||
card_available: !!data.card_available,
|
||||
source: "relay",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[billing] plans read failed: ${err?.message || err}`);
|
||||
}
|
||||
return res.json({ ...FALLBACK_PLANS, card_available: false, source: "fallback" });
|
||||
});
|
||||
|
||||
// POST /api/billing/buy body: { tier: "pro" | "max", method?: "bitcoin" | "card" }
|
||||
// Bitcoin (default) → BTCPay invoice; card → Zaprite hosted checkout.
|
||||
// Returns { ok, method, checkout_url, tier, period_days, ... }.
|
||||
app.post("/api/billing/buy", requireUser, async (req, res) => {
|
||||
// Must be a real signed-in user — a tier is keyed to a durable
|
||||
// user-id, not an anon trial cookie.
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(403).json({
|
||||
error: "must_be_signed_in",
|
||||
message: "Sign in to buy a subscription.",
|
||||
});
|
||||
}
|
||||
const tier = String(req.body?.tier || "").trim().toLowerCase();
|
||||
if (!BUYABLE_TIERS.has(tier)) {
|
||||
return res.status(400).json({
|
||||
error: "bad_tier",
|
||||
message: 'tier must be "pro" or "max"',
|
||||
});
|
||||
}
|
||||
const method = String(req.body?.method || "bitcoin").trim().toLowerCase();
|
||||
if (!PAYMENT_METHODS.has(method)) {
|
||||
return res.status(400).json({
|
||||
error: "bad_method",
|
||||
message: 'method must be "bitcoin" or "card"',
|
||||
});
|
||||
}
|
||||
const origin = originFor(req);
|
||||
// Land back on the app with a marker the frontend uses to kick an
|
||||
// immediate status sync (the modal also polls, so this is a courtesy
|
||||
// for buyers who follow the checkout redirect).
|
||||
const returnUrl = origin ? `${origin}/?billing=success` : null;
|
||||
try {
|
||||
if (method === "card") {
|
||||
const order = await createRelayZapriteOrder({
|
||||
userId: req.user.id,
|
||||
tier,
|
||||
returnUrl,
|
||||
});
|
||||
return res.json({
|
||||
ok: true,
|
||||
method: "card",
|
||||
checkout_url: order.checkout_url || null,
|
||||
order_id: order.order_id || null,
|
||||
amount: order.amount ?? null,
|
||||
currency: order.currency || null,
|
||||
tier: order.tier || tier,
|
||||
period_days: order.period_days ?? null,
|
||||
});
|
||||
}
|
||||
// Bitcoin (default) — BTCPay invoice.
|
||||
const invoice = await createRelayTierInvoice({
|
||||
userId: req.user.id,
|
||||
tier,
|
||||
returnUrl,
|
||||
});
|
||||
res.json({
|
||||
ok: true,
|
||||
method: "bitcoin",
|
||||
checkout_url: invoice.checkout_url || null,
|
||||
invoice_id: invoice.invoice_id || null,
|
||||
sats: invoice.sats ?? null,
|
||||
tier: invoice.tier || tier,
|
||||
period_days: invoice.period_days ?? null,
|
||||
// Lightning BOLT11 for the inline QR (no redirect). Null → the app
|
||||
// falls back to opening the hosted checkout_url.
|
||||
bolt11: invoice.bolt11 || null,
|
||||
lightning_payment_link: invoice.lightning_payment_link || null,
|
||||
lightning_expires_at: invoice.lightning_expires_at || null,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err?.status || 502;
|
||||
console.error(
|
||||
`[billing] buy failed for ${req.user.id} (${tier}/${method}): ${err?.message || err}`,
|
||||
);
|
||||
// 503 from the relay = that rail isn't configured; surface a hint.
|
||||
const notConfigured =
|
||||
status === 503 || /not[_ ]configured/i.test(err?.message || "");
|
||||
const rail = method === "card" ? "Card" : "Bitcoin";
|
||||
const tool = method === "card" ? "Zaprite" : "BTCPay";
|
||||
res.status(notConfigured ? 503 : 502).json({
|
||||
error: notConfigured ? "payments_unavailable" : "checkout_failed",
|
||||
message: notConfigured
|
||||
? `${rail} payments aren't set up on this server yet. Ask the operator to configure ${tool}.`
|
||||
: "Couldn't start the payment. Please try again in a moment.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/billing/status
|
||||
// Returns { tier, expires_at, synced } — the user's current relay-owned
|
||||
// tier, with the local cache refreshed as a side effect.
|
||||
app.get("/api/billing/status", requireUser, async (req, res) => {
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(403).json({ error: "must_be_signed_in" });
|
||||
}
|
||||
const synced = await syncUserTierFromRelay(req.user.id);
|
||||
if (synced.synced) {
|
||||
return res.json({
|
||||
tier: synced.tier,
|
||||
expires_at: synced.expires_at,
|
||||
tier_snapshot: synced.tier_snapshot,
|
||||
subscription_expired: synced.subscription_expired,
|
||||
synced: true,
|
||||
});
|
||||
}
|
||||
// Relay unreachable / unconfigured — fall back to the cached tier so
|
||||
// the UI still renders a sane badge instead of erroring.
|
||||
return res.json({
|
||||
tier: req.user.tier || "core",
|
||||
expires_at: null,
|
||||
synced: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
// Chunked topic-analysis: split a long transcript into overlapping
|
||||
// time-windowed slices, analyze each slice in parallel, stitch the
|
||||
// returned sections back into one coherent list.
|
||||
//
|
||||
// Why: a single-shot analyze call against a 2-hour transcript spends
|
||||
// most of its wall-time on prefill (typically 25K+ tokens). Splitting
|
||||
// into 18-min slices gives the model a much smaller prompt per call,
|
||||
// and firing the slices concurrently lets the backend (relay/vLLM or
|
||||
// Gemini) batch them. End-to-end wall-time drops from minutes to
|
||||
// tens of seconds for long content, with no quality regression as
|
||||
// long as the slice boundaries are chosen with overlap and the
|
||||
// stitcher trusts the second slice for the overlap region.
|
||||
//
|
||||
// Public entry point: runChunkedAnalysis().
|
||||
|
||||
import { buildAnalysisPrompt } from "./gemini-helpers.js";
|
||||
|
||||
// ── Tunables ────────────────────────────────────────────────────────────────
|
||||
// Window body: the part of a chunk that "owns" its topic boundaries.
|
||||
// Overlap: a tail appended to each window so a topic spanning a
|
||||
// boundary still gets seen in full by at least one window.
|
||||
// Stride = body. Windows advance by `body` seconds; each window
|
||||
// covers `body + overlap` seconds of audio.
|
||||
const WINDOW_BODY_SECONDS = 18 * 60; // 18 min
|
||||
const WINDOW_OVERLAP_SECONDS = 2 * 60; // 2 min
|
||||
// Don't chunk below this duration. A single analyze call against
|
||||
// <25 min is fast on its own and avoids the stitching complexity
|
||||
// for the common short-content case.
|
||||
// Exported so the orchestrator can mirror the decision when picking
|
||||
// whether to coalesce: above this duration the chunker handles
|
||||
// granularity per-window, so the pre-chunk coalesce is unnecessary
|
||||
// and would hurt section-boundary precision.
|
||||
export const CHUNKING_CUTOFF_SECONDS = 25 * 60; // 25 min
|
||||
// Max concurrent analyze calls in flight. Gemini paid Tier 1 allows
|
||||
// ~1000 RPM for flash and ~150 RPM for pro — 12 in-flight is well
|
||||
// under either ceiling and saturates most operator workloads
|
||||
// without queueing. Operator hardware (vLLM on a single Spark) caps
|
||||
// out around 8-12 concurrent for our prompt size, so 12 is a
|
||||
// reasonable cross-backend default.
|
||||
const DEFAULT_CONCURRENCY = 12;
|
||||
|
||||
// ── Window planning ─────────────────────────────────────────────────────────
|
||||
// Plans a set of overlapping windows over the entries array. Each
|
||||
// window has:
|
||||
// - startIdx, endIdx: inclusive bounds into the entries array
|
||||
// - bodyStartIdx: index where this window's "body" begins
|
||||
// (i.e., everything before this index is the
|
||||
// overlap with the previous window's tail)
|
||||
// The first window has bodyStartIdx === startIdx. Windows after the
|
||||
// first have bodyStartIdx > startIdx by ~overlap seconds.
|
||||
//
|
||||
// The stitcher uses bodyStartIdx of window N+1 to decide whether a
|
||||
// section from window N falls in the contested overlap region.
|
||||
export function planAnalysisWindows(entries, opts = {}) {
|
||||
const bodySec = opts.bodySeconds ?? WINDOW_BODY_SECONDS;
|
||||
const overlapSec = opts.overlapSeconds ?? WINDOW_OVERLAP_SECONDS;
|
||||
const totalSec = (entries[entries.length - 1].offset || 0) +
|
||||
(entries[entries.length - 1].duration || 0);
|
||||
const cutoffSec = opts.cutoffSeconds ?? CHUNKING_CUTOFF_SECONDS;
|
||||
if (totalSec <= cutoffSec) {
|
||||
return [{ startIdx: 0, endIdx: entries.length - 1, bodyStartIdx: 0 }];
|
||||
}
|
||||
|
||||
const windows = [];
|
||||
let bodyStartSec = 0;
|
||||
while (bodyStartSec < totalSec) {
|
||||
// The window's covered span (body + tail overlap):
|
||||
const windowEndSec = bodyStartSec + bodySec + overlapSec;
|
||||
// Body start in entry-index space: first entry with offset >= bodyStartSec.
|
||||
const bodyStartIdx = firstEntryAtOrAfter(entries, bodyStartSec);
|
||||
// If there are NO entries at or after bodyStartSec, we've consumed
|
||||
// all entries. Stop the loop.
|
||||
if (bodyStartIdx >= entries.length) break;
|
||||
// GAP HANDLING: if the next entry after bodyStartSec is far in
|
||||
// the future (past this window's body + overlap), there's a gap
|
||||
// in the transcript timeline. This commonly happens when the
|
||||
// transcribe step truncated a middle chunk — the timeline has
|
||||
// valid entries at, e.g., 0-31 min and 90-94 min but nothing in
|
||||
// between. Without this fix, the old loop would BREAK at the gap
|
||||
// (because endIdx < bodyStartIdx triggered the "sparse trailing
|
||||
// window" exit), silently dropping the entries past the gap from
|
||||
// analysis entirely. Now we jump bodyStartSec forward to the
|
||||
// next entry's offset (rounded down to a body-stride boundary
|
||||
// so subsequent window alignment stays sensible) and continue.
|
||||
const nextEntryOffset = entries[bodyStartIdx].offset || 0;
|
||||
if (nextEntryOffset >= windowEndSec) {
|
||||
bodyStartSec = Math.max(
|
||||
bodyStartSec + bodySec,
|
||||
Math.floor(nextEntryOffset / bodySec) * bodySec
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Window's entry range: from the start of overlap-with-prior
|
||||
// (i.e., bodyStartSec - overlapSec, clamped at 0) through windowEndSec.
|
||||
const overlapWithPriorSec = Math.max(0, bodyStartSec - overlapSec);
|
||||
const startIdx = firstEntryAtOrAfter(entries, overlapWithPriorSec);
|
||||
const endIdx = lastEntryBefore(entries, windowEndSec);
|
||||
if (endIdx < bodyStartIdx) {
|
||||
// Defensive: shouldn't happen with the gap-handling above, but
|
||||
// if it does, advance the body cursor rather than break so we
|
||||
// don't get stuck.
|
||||
bodyStartSec += bodySec;
|
||||
continue;
|
||||
}
|
||||
windows.push({ startIdx, endIdx, bodyStartIdx });
|
||||
// Stop if this window already covers the last entry.
|
||||
if (endIdx >= entries.length - 1) break;
|
||||
bodyStartSec += bodySec;
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
function firstEntryAtOrAfter(entries, sec) {
|
||||
// Linear scan; entries are sorted by offset.
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if ((entries[i].offset || 0) >= sec) return i;
|
||||
}
|
||||
return entries.length;
|
||||
}
|
||||
|
||||
function lastEntryBefore(entries, sec) {
|
||||
// Largest i s.t. entries[i].offset < sec.
|
||||
let ans = -1;
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if ((entries[i].offset || 0) < sec) ans = i;
|
||||
else break;
|
||||
}
|
||||
// If no entry has offset < sec, return -1 → caller treats as empty.
|
||||
// If the whole array fits, return entries.length - 1.
|
||||
return ans === -1 ? -1 : ans;
|
||||
}
|
||||
|
||||
// ── Parallel analyzer ───────────────────────────────────────────────────────
|
||||
// Fires N analyze calls concurrently with a bounded in-flight count.
|
||||
// Each call gets its own slice of entries plus a freshly-built prompt.
|
||||
// Returns array of { window, ok, sections | error, cost, model }.
|
||||
//
|
||||
// Errors are isolated per window — a single-window failure doesn't
|
||||
// fail the whole batch. The stitcher gets to decide what to do
|
||||
// about gaps.
|
||||
async function analyzeWindowsInParallel({
|
||||
entries,
|
||||
windows,
|
||||
analyzer,
|
||||
fallbackModels,
|
||||
concurrency,
|
||||
onProgress,
|
||||
onWindowComplete,
|
||||
signal,
|
||||
jobId,
|
||||
// Total audio duration in seconds — passed through to
|
||||
// buildAnalysisPrompt so the section-count target scales with the
|
||||
// full video length (not just per-window). Recap-relay does the
|
||||
// same; matching here keeps segmentation density consistent
|
||||
// across both pipelines. When omitted, buildAnalysisPrompt falls
|
||||
// back to deriving from the entries themselves.
|
||||
totalAudioSec = 0,
|
||||
}) {
|
||||
const results = new Array(windows.length);
|
||||
let next = 0;
|
||||
let completed = 0;
|
||||
|
||||
async function worker() {
|
||||
while (true) {
|
||||
if (signal?.aborted) return;
|
||||
const my = next++;
|
||||
if (my >= windows.length) return;
|
||||
const w = windows[my];
|
||||
const windowEntries = entries.slice(w.startIdx, w.endIdx + 1);
|
||||
const prompt = buildAnalysisPrompt(windowEntries, { totalAudioSec });
|
||||
// Try the configured model first, then walk fallbacks.
|
||||
let lastErr = null;
|
||||
let result = null;
|
||||
let usedModel = null;
|
||||
for (const tryModel of fallbackModels) {
|
||||
try {
|
||||
result = await analyzer.analyzeText({
|
||||
prompt,
|
||||
model: tryModel,
|
||||
onProgress: () => {}, // suppress per-chunk progress noise
|
||||
signal,
|
||||
jobId,
|
||||
});
|
||||
usedModel = tryModel;
|
||||
break;
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return;
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
if (!result) {
|
||||
results[my] = { window: w, ok: false, error: lastErr };
|
||||
completed++;
|
||||
onProgress?.(`Window ${my + 1}/${windows.length} failed: ${lastErr?.message?.slice(0, 100) || "unknown"}`);
|
||||
continue;
|
||||
}
|
||||
const parsed = safeParseSections(result.text);
|
||||
if (!parsed) {
|
||||
results[my] = { window: w, ok: false, error: new Error("invalid JSON") };
|
||||
completed++;
|
||||
onProgress?.(`Window ${my + 1}/${windows.length} returned invalid JSON`);
|
||||
continue;
|
||||
}
|
||||
results[my] = {
|
||||
window: w,
|
||||
ok: true,
|
||||
sections: parsed.sections,
|
||||
model: usedModel,
|
||||
cost: result.cost,
|
||||
};
|
||||
completed++;
|
||||
onProgress?.(`Window ${my + 1}/${windows.length} done (${parsed.sections.length} topics)`);
|
||||
|
||||
// Fire the streaming callback with this window's BODY-OWNED
|
||||
// sections — the ones the final stitcher will keep from this
|
||||
// window. Computed deterministically per-window so the UI can
|
||||
// render incrementally as windows arrive (even out of order),
|
||||
// without later having to "undo" any displayed sections.
|
||||
//
|
||||
// Rule: window N owns sections whose globalStart falls before
|
||||
// window(N+1).bodyStartIdx. Sections starting at or after the
|
||||
// next window's body are deferred — window N+1 will produce an
|
||||
// authoritative version of them with more downstream context.
|
||||
if (onWindowComplete) {
|
||||
const nextBody = my + 1 < windows.length
|
||||
? windows[my + 1].bodyStartIdx
|
||||
: Infinity;
|
||||
const offset = w.startIdx;
|
||||
const owned = [];
|
||||
for (const s of parsed.sections) {
|
||||
const globalStart = offset + (s.startIndex ?? 0);
|
||||
const globalEnd = offset + (s.endIndex ?? 0);
|
||||
if (globalStart >= nextBody) continue;
|
||||
owned.push({
|
||||
startIndex: globalStart,
|
||||
endIndex: globalEnd,
|
||||
title: s.title,
|
||||
summary: s.summary,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await onWindowComplete({
|
||||
windowIdx: my,
|
||||
totalWindows: windows.length,
|
||||
ownedSections: owned,
|
||||
});
|
||||
} catch (cbErr) {
|
||||
// Callback errors must not derail the analyze loop —
|
||||
// streaming is best-effort and the canonical result still
|
||||
// ships at the end.
|
||||
console.warn(
|
||||
`[chunked-analyze] onWindowComplete callback failed: ${cbErr?.message || cbErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, windows.length) }, worker);
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function safeParseSections(text) {
|
||||
if (!text) return null;
|
||||
let jsonStr = text.trim();
|
||||
const cb = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (cb) jsonStr = cb[1].trim();
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
if (!parsed || !Array.isArray(parsed.sections)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stitcher ────────────────────────────────────────────────────────────────
|
||||
// Merges per-window section lists into a single ordered list of
|
||||
// non-overlapping sections referencing entries by their position in
|
||||
// the FULL (un-chunked) entries array.
|
||||
//
|
||||
// The rule: each window N owns sections whose globalStart falls in
|
||||
// its body (i.e., globalStart < window(N+1).bodyStartIdx). Any
|
||||
// section starting at or after the next window's body boundary is
|
||||
// dropped because the next window will have produced a better
|
||||
// version of that same section with more downstream context. The
|
||||
// last window has no successor, so all its sections are kept.
|
||||
//
|
||||
// After collection, sections are sorted and any residual overlap
|
||||
// (which shouldn't happen if windows are well-formed but might
|
||||
// arise from model index errors) is repaired by clamping endIndex
|
||||
// to the next section's startIndex - 1.
|
||||
export function stitchAnalysisResults(results) {
|
||||
const out = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const r = results[i];
|
||||
if (!r || !r.ok) continue;
|
||||
const next = results[i + 1];
|
||||
const nextBody = next && next.window
|
||||
? next.window.bodyStartIdx
|
||||
: Infinity;
|
||||
const offset = r.window.startIdx;
|
||||
for (const s of r.sections) {
|
||||
const globalStart = offset + (s.startIndex ?? 0);
|
||||
const globalEnd = offset + (s.endIndex ?? 0);
|
||||
// Drop sections that begin in the next window's body — the
|
||||
// next window's analysis is authoritative for that range.
|
||||
if (globalStart >= nextBody) continue;
|
||||
out.push({
|
||||
startIndex: globalStart,
|
||||
endIndex: globalEnd,
|
||||
title: s.title,
|
||||
summary: s.summary,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Order + repair overlaps (defensive — shouldn't trigger with
|
||||
// well-behaved model output, but the existing single-shot path
|
||||
// doesn't either and this matches its robustness).
|
||||
out.sort((a, b) => a.startIndex - b.startIndex);
|
||||
for (let i = 0; i < out.length - 1; i++) {
|
||||
if (out[i].endIndex >= out[i + 1].startIndex) {
|
||||
out[i].endIndex = out[i + 1].startIndex - 1;
|
||||
}
|
||||
}
|
||||
return out.filter((s) => s.endIndex >= s.startIndex);
|
||||
}
|
||||
|
||||
// ── Public entry point ──────────────────────────────────────────────────────
|
||||
// Runs chunked analysis end-to-end. Returns the same envelope shape
|
||||
// callers expect from a single-shot analyzer.analyzeText() call:
|
||||
// {
|
||||
// text: "<JSON string with .sections>", // for prompt/result parity
|
||||
// model: "<which model served the most windows>",
|
||||
// cost: { total cost across all windows, summed },
|
||||
// usage: null, // no aggregate usage
|
||||
// attempts: { windows: N, failed: K } // diagnostic
|
||||
// }
|
||||
// The caller parses .text the same way it parses a single-shot
|
||||
// response — no changes to the downstream chunk-building code.
|
||||
//
|
||||
// Falls back to single-shot if planning produces just one window
|
||||
// (i.e., content is below the chunking cutoff). If all windows fail,
|
||||
// throws so the caller's existing fallback (try next model) kicks in.
|
||||
export async function runChunkedAnalysis({
|
||||
entries,
|
||||
analyzer,
|
||||
fallbackModels,
|
||||
concurrency = DEFAULT_CONCURRENCY,
|
||||
onProgress = () => {},
|
||||
onWindowComplete = null,
|
||||
signal,
|
||||
jobId,
|
||||
}) {
|
||||
const windows = planAnalysisWindows(entries);
|
||||
if (windows.length === 1) {
|
||||
// Single-shot path — same as the legacy code does, but routed
|
||||
// through here so callers have one entry point. Log message
|
||||
// distinguishes the two reasons we end up here:
|
||||
// (a) totalSec ≤ cutoff — short content, intentionally not chunked
|
||||
// (b) entries are too sparse for multi-window planning — the loop
|
||||
// broke after one window. Surfaces an awkward state that's
|
||||
// usually a sign of bad upstream data (e.g. transcribe emitted
|
||||
// bogus far-future timestamps that the sanity-cap dropped).
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
const totalSec = (lastEntry?.offset || 0) + (lastEntry?.duration || 0);
|
||||
if (totalSec <= CHUNKING_CUTOFF_SECONDS) {
|
||||
onProgress(
|
||||
`Content ≤${Math.round(CHUNKING_CUTOFF_SECONDS / 60)} min — running single-shot analysis`
|
||||
);
|
||||
} else {
|
||||
onProgress(
|
||||
`Single window planned over ${entries.length} entries (last @ ${Math.round(totalSec / 60)} min) — running single-shot analysis`
|
||||
);
|
||||
}
|
||||
return await runSingleShot({
|
||||
entries,
|
||||
analyzer,
|
||||
fallbackModels,
|
||||
onProgress,
|
||||
signal,
|
||||
jobId,
|
||||
});
|
||||
}
|
||||
onProgress(
|
||||
`Chunked analysis: ${windows.length} windows of ~18 min each, up to ${concurrency} in parallel`
|
||||
);
|
||||
// Compute total audio duration from the last entry's offset so the
|
||||
// section-count target (in buildAnalysisPrompt) scales with the
|
||||
// FULL video length, not just per-window. Matches recap-relay's
|
||||
// per-video-duration target methodology for consistent segmentation
|
||||
// density across both pipelines.
|
||||
const totalAudioSec = entries.length > 0
|
||||
? (entries[entries.length - 1].offset || 0) + (entries[entries.length - 1].duration || 0)
|
||||
: 0;
|
||||
const results = await analyzeWindowsInParallel({
|
||||
entries,
|
||||
windows,
|
||||
analyzer,
|
||||
fallbackModels,
|
||||
concurrency,
|
||||
onProgress,
|
||||
onWindowComplete,
|
||||
signal,
|
||||
jobId,
|
||||
totalAudioSec,
|
||||
});
|
||||
// If the caller aborted mid-flight, some result slots may be empty.
|
||||
// Surface cancellation cleanly to the outer pipeline.
|
||||
if (signal?.aborted) {
|
||||
const e = new Error("aborted");
|
||||
e.name = "AbortError";
|
||||
throw e;
|
||||
}
|
||||
const completed = results.filter(Boolean);
|
||||
const failures = completed.filter((r) => !r.ok);
|
||||
if (completed.length === 0 || failures.length === completed.length) {
|
||||
throw new Error(
|
||||
`All ${results.length} analyze windows failed. First error: ${
|
||||
failures[0]?.error?.message || "unknown"
|
||||
}`
|
||||
);
|
||||
}
|
||||
const stitched = stitchAnalysisResults(results);
|
||||
// Aggregate model attribution: pick the most-used successful model.
|
||||
const modelTally = new Map();
|
||||
let totalCost = 0;
|
||||
for (const r of results) {
|
||||
if (!r.ok) continue;
|
||||
modelTally.set(r.model, (modelTally.get(r.model) || 0) + 1);
|
||||
const c = typeof r.cost?.totalCost === "string"
|
||||
? parseFloat(r.cost.totalCost)
|
||||
: r.cost?.totalCost || 0;
|
||||
if (Number.isFinite(c)) totalCost += c;
|
||||
}
|
||||
const dominantModel = [...modelTally.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || null;
|
||||
onProgress(
|
||||
`Chunked analysis complete — ${results.length - failures.length}/${results.length} windows succeeded, ${stitched.length} topics`
|
||||
);
|
||||
return {
|
||||
text: JSON.stringify({ sections: stitched }),
|
||||
model: dominantModel,
|
||||
cost: {
|
||||
totalCost: totalCost.toFixed(6),
|
||||
totalCostDisplay: totalCost < 0.01
|
||||
? `$${(totalCost * 100).toFixed(3)}¢`
|
||||
: `$${totalCost.toFixed(4)}`,
|
||||
},
|
||||
usage: null,
|
||||
attempts: { windows: results.length, failed: failures.length },
|
||||
};
|
||||
}
|
||||
|
||||
async function runSingleShot({
|
||||
entries,
|
||||
analyzer,
|
||||
fallbackModels,
|
||||
onProgress,
|
||||
signal,
|
||||
jobId,
|
||||
}) {
|
||||
// Single-shot path: the whole transcript IS the "window". Compute
|
||||
// totalAudioSec from the entries so the section-count target picker
|
||||
// chooses the right bucket (<30 min → 6 sections, 30-60 → 8, etc.).
|
||||
const totalAudioSec = entries.length > 0
|
||||
? (entries[entries.length - 1].offset || 0) + (entries[entries.length - 1].duration || 0)
|
||||
: 0;
|
||||
const prompt = buildAnalysisPrompt(entries, { totalAudioSec });
|
||||
let lastErr = null;
|
||||
for (const tryModel of fallbackModels) {
|
||||
try {
|
||||
const result = await analyzer.analyzeText({
|
||||
prompt,
|
||||
model: tryModel,
|
||||
onProgress,
|
||||
signal,
|
||||
jobId,
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
throw lastErr || new Error("All analysis models failed");
|
||||
}
|
||||
@@ -25,6 +25,14 @@ let startosConfigPath = null;
|
||||
|
||||
export let serverApiKey = "";
|
||||
|
||||
// Core-decoupling shared "operator key" — read live from the StartOS
|
||||
// config sidecar the same way serverApiKey is, so the operator can set it
|
||||
// via the "Set Relay Operator Key" action without a service restart.
|
||||
// `RECAP_RELAY_OPERATOR_KEY` env pins the value (local dev). Consumed by
|
||||
// relay-default.js's getRelayOperatorKey(); see that for the semantics.
|
||||
let envRelayOperatorKey = "";
|
||||
export let relayOperatorKey = "";
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────────────────
|
||||
// Call once at boot. Sets up paths, reads the initial value, kicks off the
|
||||
// poll loop. Idempotent if you really want to call it twice (the interval
|
||||
@@ -35,13 +43,17 @@ export async function initConfig({ dataDir }) {
|
||||
startosConfigPath = path.join(configDir, "startos-config.json");
|
||||
envApiKey = process.env.GEMINI_API_KEY || "";
|
||||
serverApiKey = envApiKey;
|
||||
envRelayOperatorKey = (process.env.RECAP_RELAY_OPERATOR_KEY || "").trim();
|
||||
relayOperatorKey = envRelayOperatorKey;
|
||||
|
||||
await fs.mkdir(configDir, { recursive: true }).catch(() => {});
|
||||
await refreshServerApiKey("startup");
|
||||
await refreshRelayOperatorKey("startup");
|
||||
|
||||
const pollMs = parseInt(process.env.RECAP_CONFIG_POLL_MS || "3000", 10);
|
||||
setInterval(() => {
|
||||
refreshServerApiKey("config poll").catch(() => {});
|
||||
refreshRelayOperatorKey("config poll").catch(() => {});
|
||||
}, pollMs);
|
||||
}
|
||||
|
||||
@@ -75,6 +87,27 @@ async function refreshServerApiKey(reason) {
|
||||
}
|
||||
}
|
||||
|
||||
async function readRelayOperatorKeyFromConfig() {
|
||||
try {
|
||||
const content = await fs.readFile(startosConfigPath, "utf-8");
|
||||
const config = JSON.parse(content);
|
||||
return (config.recap_relay_operator_key || "").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRelayOperatorKey(reason) {
|
||||
if (envRelayOperatorKey) return; // env var pins the value
|
||||
const next = await readRelayOperatorKeyFromConfig();
|
||||
if (next !== relayOperatorKey) {
|
||||
relayOperatorKey = next;
|
||||
console.log(
|
||||
`[config] relay operator key ${next ? "loaded" : "cleared"} (${reason})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public helpers ──────────────────────────────────────────────────────────
|
||||
// Resolves the per-request key — either the client's own (BYO) or the
|
||||
// server's stored key (when the client signals USE_SERVER_KEY or sends
|
||||
@@ -132,4 +165,7 @@ export async function mergeConfig(patch) {
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "gemini_api_key")) {
|
||||
await refreshServerApiKey("merge config");
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "recap_relay_operator_key")) {
|
||||
await refreshRelayOperatorKey("merge config");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
// Recap-side proxy to the relay's credit-purchase endpoints.
|
||||
//
|
||||
// Architecture is identical to license-purchase.js: Recap doesn't
|
||||
// hold the BTCPay credentials, the relay does. Recap just forwards
|
||||
// the buyer's pick to the relay and proxies the polling. The relay
|
||||
// returns the BTCPay checkout URL which the Recap UI displays in a
|
||||
// modal styled to match the license-purchase modal.
|
||||
//
|
||||
// Endpoints:
|
||||
// GET /api/credits/packages → relay GET /relay/credits/packages
|
||||
// POST /api/credits/buy → relay POST /relay/credits/buy
|
||||
// GET /api/credits/invoice/:id → relay GET /relay/credits/invoice/:id
|
||||
//
|
||||
// Auth headers (X-Recap-Install-Id + Authorization Bearer LIC1-...)
|
||||
// are added by this proxy, not by the buyer-side JS — keeping the
|
||||
// install identity + license key out of any client-side code.
|
||||
|
||||
import { getRelayBaseURL } from "./relay-default.js";
|
||||
import { getInstallId } from "./install-id.js";
|
||||
import { getRawLicenseKey } from "./license.js";
|
||||
|
||||
// Multi-mode toggle. In multi mode every credit purchase is recorded
|
||||
// in pending_purchases so we know WHO (signed-in user vs. anon trial
|
||||
// cookie) to credit locally when the invoice settles. Single mode is
|
||||
// the legacy "operator-pool only" flow — no local accounting layer,
|
||||
// the relay's credits.json IS the source of truth.
|
||||
const RECAP_MODE = process.env.RECAP_MODE === "multi" ? "multi" : "single";
|
||||
|
||||
function relayHeaders({ json = false, req = null } = {}) {
|
||||
const h = {};
|
||||
// Identity routing for the credit-purchase + credit-poll flow:
|
||||
//
|
||||
// Pro/Max signed-in tenant (req.user.keysat_license set)
|
||||
// → use THEIR install ID + license. The buy invoice gets
|
||||
// stashed with THEIR license_fingerprint so the BTCPay
|
||||
// settle-webhook credits THEIR license-keyed pool — the
|
||||
// same pool /api/relay/status reads when displaying their
|
||||
// balance. Without this, credits land on the operator's
|
||||
// pool and the buyer sees their balance unchanged after
|
||||
// paying (the bug Grant hit on 2026-05-18).
|
||||
//
|
||||
// Anon visitor (trial cookie only) / free signed-in tenant
|
||||
// (no license) / single-mode operator
|
||||
// → fall back to operator identity. The operator's pool is
|
||||
// what's being topped up; Recaps' own accounting layer
|
||||
// (anon_trials / tenant_credits) handles the per-user
|
||||
// attribution locally via pending_purchases.
|
||||
let installId = null;
|
||||
let licenseKey = null;
|
||||
if (req?.user?.keysat_license && req.user.synthetic_install_id) {
|
||||
installId = req.user.synthetic_install_id;
|
||||
licenseKey = req.user.keysat_license;
|
||||
}
|
||||
if (!installId) {
|
||||
try {
|
||||
const id = getInstallId();
|
||||
if (id) installId = id;
|
||||
} catch {}
|
||||
}
|
||||
if (!licenseKey) {
|
||||
try {
|
||||
const key = getRawLicenseKey();
|
||||
if (key) licenseKey = key;
|
||||
} catch {}
|
||||
}
|
||||
if (installId) h["X-Recap-Install-Id"] = installId;
|
||||
if (licenseKey) h["Authorization"] = `Bearer ${licenseKey}`;
|
||||
if (json) h["Content-Type"] = "application/json";
|
||||
return h;
|
||||
}
|
||||
|
||||
export function setupCreditsPurchaseRoutes(app) {
|
||||
// List bundles the operator has configured. Cheap, no auth gating —
|
||||
// the buyer needs the price menu before they decide whether to pay.
|
||||
app.get("/api/credits/packages", async (_req, res) => {
|
||||
const base = getRelayBaseURL();
|
||||
if (!base) {
|
||||
return res.status(503).json({
|
||||
error: "relay_not_configured",
|
||||
message: "Relay base URL not set on this Recaps install.",
|
||||
});
|
||||
}
|
||||
try {
|
||||
// 10s timeout — was 5s, but a cold relay request from mobile
|
||||
// cellular can take 6-8s, and Safari iOS surfaces the abort as
|
||||
// a generic "Load failed" with no other info, so the buyer
|
||||
// sees an error and has to manually retry. 10s is still snappy
|
||||
// enough that a legit failure doesn't hang the UI for long.
|
||||
const r = await fetch(`${base.replace(/\/$/, "")}/relay/credits/packages`, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const text = await r.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
if (!r.ok) {
|
||||
return res.status(r.status).json(body || { error: "relay_packages_failed" });
|
||||
}
|
||||
res.json(body || { packages: [] });
|
||||
} catch (err) {
|
||||
console.error(`[credits/packages] failed: ${err?.message || err}`);
|
||||
res.status(502).json({
|
||||
error: "packages_fetch_failed",
|
||||
message: (err?.message || String(err)).slice(0, 300),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initiate a purchase. Body: { credits: 1|5|10|20 }. Returns the
|
||||
// raw relay envelope (so the UI sees credits_remaining + tier +
|
||||
// result.checkout_url + result.invoice_id).
|
||||
//
|
||||
// Multi-mode: identifies the buyer (signed-in user or anon trial
|
||||
// cookie), records a pending_purchases row keyed by the invoice_id
|
||||
// the relay returns. The settle-handler (in /api/credits/invoice/:id
|
||||
// below) uses that row to know WHERE to apply the credits locally.
|
||||
//
|
||||
// Single-mode: skips the pending_purchases bookkeeping entirely;
|
||||
// the operator IS the buyer and the relay's credits.json directly
|
||||
// tracks their pool.
|
||||
app.post("/api/credits/buy", async (req, res) => {
|
||||
const base = getRelayBaseURL();
|
||||
if (!base) {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ error: "relay_not_configured" });
|
||||
}
|
||||
const credits = Number(req.body?.credits);
|
||||
const returnUrl =
|
||||
typeof req.body?.return_url === "string" && req.body.return_url.startsWith("http")
|
||||
? req.body.return_url
|
||||
: null;
|
||||
if (!Number.isFinite(credits) || credits <= 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "credits_required" });
|
||||
}
|
||||
|
||||
// Identify the buyer for multi-mode. Either a signed-in user OR
|
||||
// an anon trial cookie. If neither, attempt to auto-mint a trial
|
||||
// cookie — anon visitors who click "Buy more" from the toolbar
|
||||
// (before they've spent their pre-trial allowance) shouldn't be
|
||||
// dead-ended into a sign-in nag. Same auto-mint pattern as
|
||||
// /api/process for pre-trial visitors. Only refuse if trials are
|
||||
// disabled or the IP is over its lifetime cap.
|
||||
let buyerType = null;
|
||||
let buyerId = null;
|
||||
if (RECAP_MODE === "multi") {
|
||||
if (req.user && req.user.id) {
|
||||
buyerType = "user";
|
||||
buyerId = req.user.id;
|
||||
} else if (req.trial && req.trial.cookie_id) {
|
||||
buyerType = "anon";
|
||||
buyerId = req.trial.cookie_id;
|
||||
} else {
|
||||
// Try to mint a fresh trial cookie so the purchase has
|
||||
// somewhere to land. forceMint=true bypasses the lifetime
|
||||
// IP cap and the trials-disabled config — a paying buyer is
|
||||
// by definition not abusing a free quota, and without a
|
||||
// tracking cookie the settle handler has nowhere to credit
|
||||
// the purchase locally (the relay still credits the operator
|
||||
// pool; we just lose the visibility to apply it to this
|
||||
// specific buyer).
|
||||
try {
|
||||
const { issueIfEligible } = await import("./anon-trial.js");
|
||||
const trial = await issueIfEligible({ req, res, forceMint: true });
|
||||
if (trial) {
|
||||
buyerType = "anon";
|
||||
buyerId = trial.cookie_id;
|
||||
// Stash on req for downstream code paths
|
||||
req.trial = trial;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[credits/buy] anon-trial mint failed:",
|
||||
err?.message || err,
|
||||
);
|
||||
}
|
||||
if (!buyerId) {
|
||||
return res.status(401).json({
|
||||
error: "buyer_unknown",
|
||||
message:
|
||||
"Couldn't create a buyer record for this purchase. Sign up for a free account so we have somewhere to credit it.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await fetch(`${base.replace(/\/$/, "")}/relay/credits/buy`, {
|
||||
method: "POST",
|
||||
headers: relayHeaders({ json: true, req }),
|
||||
body: JSON.stringify({ credits, return_url: returnUrl || undefined }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
const text = await r.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
if (!r.ok) {
|
||||
return res
|
||||
.status(r.status)
|
||||
.json(body || { error: "relay_buy_failed" });
|
||||
}
|
||||
|
||||
// Record the pending purchase BEFORE we respond, so even if the
|
||||
// browser refreshes / crashes between buy + settle, the next
|
||||
// poll for this invoice id will still know who to credit.
|
||||
// Invoice id lives under result.invoice_id per the relay's
|
||||
// envelope contract (same shape license-purchase uses).
|
||||
const invoiceId =
|
||||
body?.result?.invoice_id ||
|
||||
body?.invoice_id ||
|
||||
body?.btcpay_invoice_id ||
|
||||
null;
|
||||
if (RECAP_MODE === "multi") {
|
||||
if (!invoiceId) {
|
||||
// Loud warning — without an invoice id we can't reconcile
|
||||
// on settle. Surface the response shape so we can see what
|
||||
// the relay actually returned and fix the field-name
|
||||
// assumption if this fires.
|
||||
console.warn(
|
||||
`[credits/buy] NO invoice_id in relay response — skipping pending_purchases. Top-level keys: ${Object.keys(body || {}).join(", ")} | result keys: ${Object.keys(body?.result || {}).join(", ")}`,
|
||||
);
|
||||
} else if (!buyerType || !buyerId) {
|
||||
console.warn(
|
||||
`[credits/buy] invoice ${invoiceId}: buyer identity missing — won't auto-apply on settle.`,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const { getDb } = await import("./db.js");
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO pending_purchases
|
||||
(invoice_id, buyer_type, buyer_id, credits, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(invoiceId, buyerType, buyerId, credits, Date.now());
|
||||
console.log(
|
||||
`[credits/buy] tracked pending purchase invoice=${invoiceId} buyer=${buyerType}:${buyerId} credits=${credits} rowsInserted=${result.changes}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[credits/buy] failed to record pending purchase ${invoiceId}: ${err?.message || err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json(body || {});
|
||||
} catch (err) {
|
||||
console.error(`[credits/buy] failed: ${err?.message || err}`);
|
||||
res.status(502).json({
|
||||
error: "purchase_failed",
|
||||
message: (err?.message || String(err)).slice(0, 300),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Poll an invoice's status. Returns the relay envelope; the UI
|
||||
// reads `result.status` ("new" | "processing" | "settled" |
|
||||
// "expired" | "invalid") and refreshes when settled.
|
||||
//
|
||||
// Multi-mode side effect: when the relay reports settled, we look
|
||||
// up the matching pending_purchases row and apply the credits to
|
||||
// the right local balance. Idempotent via applied_at — if the same
|
||||
// invoice is polled multiple times after settle, only the first
|
||||
// application takes effect.
|
||||
app.get("/api/credits/invoice/:id", async (req, res) => {
|
||||
const base = getRelayBaseURL();
|
||||
if (!base) {
|
||||
return res.status(503).json({ error: "relay_not_configured" });
|
||||
}
|
||||
const id = (req.params.id || "").trim();
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: "missing_invoice_id" });
|
||||
}
|
||||
try {
|
||||
const r = await fetch(
|
||||
`${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(id)}`,
|
||||
{
|
||||
headers: relayHeaders({ req }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}
|
||||
);
|
||||
const text = await r.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
if (!r.ok) {
|
||||
return res
|
||||
.status(r.status)
|
||||
.json(body || { error: "relay_poll_failed" });
|
||||
}
|
||||
|
||||
// Multi-mode: settle-and-apply. Status path mirrors the
|
||||
// license-purchase poll-settle handler.
|
||||
const status =
|
||||
body?.result?.status || body?.status || null;
|
||||
if (RECAP_MODE === "multi" && status === "settled") {
|
||||
try {
|
||||
await applyPendingPurchase(id);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[credits/invoice] apply failed for ${id}: ${err?.message || err}`,
|
||||
);
|
||||
// Don't fail the response — the relay reported settled and
|
||||
// the operator pool has the credits. Local apply can be
|
||||
// retried by hitting this endpoint again, or by a future
|
||||
// reconciliation tool.
|
||||
}
|
||||
}
|
||||
|
||||
res.json(body || {});
|
||||
} catch (err) {
|
||||
console.error(`[credits/invoice] failed: ${err?.message || err}`);
|
||||
res.status(502).json({
|
||||
error: "poll_failed",
|
||||
message: (err?.message || String(err)).slice(0, 300),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/credits/claim { invoice_id }
|
||||
// Manual self-service recovery: a signed-in user pastes the BTCPay
|
||||
// invoice ID of a purchase they made anonymously (e.g., Safari
|
||||
// Private mode where the trial cookie didn't survive the magic-
|
||||
// link click). We verify the invoice is settled at the relay AND
|
||||
// the pending_purchases row is anon-buyer + unapplied, then credit
|
||||
// their account.
|
||||
//
|
||||
// Safety:
|
||||
// - Requires authenticated user (req.user.id must be set)
|
||||
// - Only claims buyer_type='anon' rows (no user-to-user takeover)
|
||||
// - applied_at idempotency guard prevents double-credit
|
||||
// - BTCPay invoice IDs are 30+ char random — not enumerable
|
||||
// - User-buyer rows are never claimable here, regardless of
|
||||
// ownership — those are the cookie sweep's job
|
||||
app.post("/api/credits/claim", async (req, res) => {
|
||||
if (RECAP_MODE !== "multi") {
|
||||
return res.status(404).json({ error: "not_available" });
|
||||
}
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(401).json({
|
||||
error: "auth_required",
|
||||
message: "Sign in first to claim a purchase to your account.",
|
||||
});
|
||||
}
|
||||
const invoiceId = String(req.body?.invoice_id || "").trim();
|
||||
if (!invoiceId) {
|
||||
return res.status(400).json({
|
||||
error: "missing_invoice_id",
|
||||
message: "Paste the invoice ID from your purchase email.",
|
||||
});
|
||||
}
|
||||
|
||||
const { getDb } = await import("./db.js");
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT invoice_id, buyer_type, buyer_id, credits, applied_at
|
||||
FROM pending_purchases WHERE invoice_id = ?`,
|
||||
)
|
||||
.get(invoiceId);
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({
|
||||
error: "invoice_not_found",
|
||||
message:
|
||||
"We don't have a record of that invoice ID. Double-check it — the ID is shown in your BTCPay payment confirmation.",
|
||||
});
|
||||
}
|
||||
if (row.buyer_type !== "anon") {
|
||||
// user-buyer rows are claimable only by their original buyer
|
||||
// (cookie sweep) — refusing this avoids user-to-user takeover.
|
||||
return res.status(403).json({
|
||||
error: "not_anon_purchase",
|
||||
message:
|
||||
"This invoice was bought from a signed-in account and can only be claimed by that account.",
|
||||
});
|
||||
}
|
||||
if (row.applied_at) {
|
||||
return res.status(409).json({
|
||||
error: "already_applied",
|
||||
message:
|
||||
"Those credits were already applied. Check your balance — they may have transferred automatically.",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify settled at the relay before crediting. We do NOT trust
|
||||
// the local row alone — the buyer could have initiated the
|
||||
// invoice and never paid; without this check, anyone could
|
||||
// claim N credits just by knowing an invoice ID.
|
||||
const base = getRelayBaseURL();
|
||||
if (!base) {
|
||||
return res.status(503).json({ error: "relay_not_configured" });
|
||||
}
|
||||
let status = null;
|
||||
try {
|
||||
const r = await fetch(
|
||||
`${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(invoiceId)}`,
|
||||
{ headers: relayHeaders({ req }), signal: AbortSignal.timeout(10_000) },
|
||||
);
|
||||
if (r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
status = body?.result?.status || body?.status || null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[credits/claim] relay status check failed for ${invoiceId}: ${err?.message || err}`,
|
||||
);
|
||||
return res.status(502).json({
|
||||
error: "relay_unreachable",
|
||||
message:
|
||||
"Couldn't verify the invoice with the payment server. Try again in a minute.",
|
||||
});
|
||||
}
|
||||
if (status !== "settled") {
|
||||
return res.status(409).json({
|
||||
error: "not_settled",
|
||||
message: `That invoice is not settled (status: ${status || "unknown"}). If you just paid, wait a minute and try again.`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await applyPendingPurchase(invoiceId, { forceUserId: req.user.id });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[credits/claim] apply failed for ${invoiceId}: ${err?.message || err}`,
|
||||
);
|
||||
return res.status(500).json({
|
||||
error: "apply_failed",
|
||||
message: "Something went wrong applying the credits. Try again.",
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
`[credits/claim] user ${req.user.id} claimed invoice ${invoiceId} (${row.credits} credits)`,
|
||||
);
|
||||
res.json({ ok: true, credits: row.credits });
|
||||
});
|
||||
}
|
||||
|
||||
// applyPendingPurchase(invoiceId, opts?) — credit the buyer's local
|
||||
// balance for a settled invoice. Idempotent: bails if the row is
|
||||
// already marked applied. If the buyer was an anon trial that has
|
||||
// since been converted to a real user, credits route to the user
|
||||
// instead.
|
||||
//
|
||||
// opts.forceUserId (optional) — route credits to this user instead
|
||||
// of the row's recorded buyer. Used by the manual-claim endpoint:
|
||||
// when a signed-in user pastes a BTCPay invoice ID for an anon
|
||||
// purchase whose trial cookie was lost (e.g., Safari Private mode
|
||||
// where the magic-link click landed in a different cookie jar), we
|
||||
// trust the invoice ID as proof-of-ownership and direct the credits
|
||||
// to their tenant_credits.
|
||||
//
|
||||
// Exported so the sweep helper below — and any future server-side
|
||||
// flow that wants to reconcile a known-settled invoice — can call it
|
||||
// without going through the /api/credits/invoice/:id route.
|
||||
export async function applyPendingPurchase(invoiceId, opts = {}) {
|
||||
const { getDb } = await import("./db.js");
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT invoice_id, buyer_type, buyer_id, credits, applied_at
|
||||
FROM pending_purchases WHERE invoice_id = ?`,
|
||||
)
|
||||
.get(invoiceId);
|
||||
if (!row) {
|
||||
// Either the buy came from a different Recap instance, or the
|
||||
// bookkeeping insert in /api/credits/buy failed earlier. Nothing
|
||||
// to do; the operator pool still has the credits from BTCPay.
|
||||
// Log so operator can reconcile manually if this fires.
|
||||
console.warn(
|
||||
`[credits/invoice] settled invoice ${invoiceId} has NO matching pending_purchases row — local balance NOT auto-applied. The credits ARE in the operator pool at the relay; operator should grant manually to the buyer.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (row.applied_at) {
|
||||
return; // already applied, idempotent no-op
|
||||
}
|
||||
|
||||
// Resolve buyer → target user_id (for tenant_credits) or trial
|
||||
// cookie_id (for anon_trials.credits_total). Anon-buyers who have
|
||||
// since converted to a real user get their credits routed to the
|
||||
// user's tenant_credits — that's the cleaner outcome and matches
|
||||
// the "credits transfer on signup" semantics the design promises.
|
||||
let targetUserId = null;
|
||||
let targetCookieId = null;
|
||||
if (opts.forceUserId) {
|
||||
targetUserId = opts.forceUserId;
|
||||
} else if (row.buyer_type === "user") {
|
||||
targetUserId = row.buyer_id;
|
||||
} else if (row.buyer_type === "anon") {
|
||||
const trial = db
|
||||
.prepare(
|
||||
"SELECT cookie_id, converted_to_user_id FROM anon_trials WHERE cookie_id = ?",
|
||||
)
|
||||
.get(row.buyer_id);
|
||||
if (trial?.converted_to_user_id) {
|
||||
targetUserId = trial.converted_to_user_id;
|
||||
} else {
|
||||
targetCookieId = row.buyer_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply + mark applied in one transaction so a crash mid-way
|
||||
// doesn't leave a half-credited buyer. Purchased credits land in
|
||||
// the PERMANENT bucket (purchased_balance) so they're not wiped on
|
||||
// the next replenishment refresh.
|
||||
const tx = db.transaction(() => {
|
||||
if (targetUserId) {
|
||||
const existing = db
|
||||
.prepare("SELECT user_id FROM tenant_credits WHERE user_id = ?")
|
||||
.get(targetUserId);
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE tenant_credits
|
||||
SET purchased_balance = purchased_balance + ?,
|
||||
lifetime_granted = lifetime_granted + ?
|
||||
WHERE user_id = ?`,
|
||||
).run(row.credits, row.credits, targetUserId);
|
||||
} else {
|
||||
db.prepare(
|
||||
`INSERT INTO tenant_credits
|
||||
(user_id, purchased_balance, replenish_balance, last_replenish_at,
|
||||
lifetime_granted, lifetime_consumed)
|
||||
VALUES (?, ?, 0, ?, ?, 0)`,
|
||||
).run(targetUserId, row.credits, Date.now(), row.credits);
|
||||
}
|
||||
} else if (targetCookieId) {
|
||||
// Anon trial: credits go into the trial's credits_total (single
|
||||
// bucket — anons don't have the purchased/replenish split).
|
||||
// They'll move to purchased_balance on signup via linkToUser.
|
||||
db.prepare(
|
||||
`UPDATE anon_trials
|
||||
SET credits_total = credits_total + ?
|
||||
WHERE cookie_id = ?`,
|
||||
).run(row.credits, targetCookieId);
|
||||
}
|
||||
db.prepare(
|
||||
"UPDATE pending_purchases SET applied_at = ? WHERE invoice_id = ?",
|
||||
).run(Date.now(), invoiceId);
|
||||
});
|
||||
tx();
|
||||
console.log(
|
||||
`[credits/invoice] applied ${row.credits} credits for ${row.buyer_type}:${row.buyer_id} → ${
|
||||
targetUserId ? "user " + targetUserId : "anon " + targetCookieId
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
// sweepUnappliedPurchases({ buyerType, buyerId, cookieIds }) — catch
|
||||
// up on settled-but-unapplied purchases for a buyer.
|
||||
//
|
||||
// Why this exists: the buy → BTCPay → settle → apply pipeline depends
|
||||
// on the buyer's browser tab polling /api/credits/invoice/:id after
|
||||
// BTCPay redirects back. But BTCPay redirects in the SAME tab (the
|
||||
// poll loop dies before it gets a chance to see "settled"), and even
|
||||
// when the redirect lands back on Recap the buyer might close it
|
||||
// before the next poll tick. Result: the relay knows the invoice is
|
||||
// settled and the operator pool has the credits, but the LOCAL
|
||||
// pending_purchases row never flips to applied — so the buyer's
|
||||
// balance stays stale until they manually re-poll, which they have no
|
||||
// way to do.
|
||||
//
|
||||
// Fix: opportunistically sweep on every /api/account/whoami and
|
||||
// /api/relay/status. Cheap (small bounded query + a few relay HTTP
|
||||
// calls), idempotent (applyPendingPurchase no-ops on already-applied
|
||||
// rows), and self-healing.
|
||||
//
|
||||
// Also called from anon-trial.js linkToUser BEFORE the transfer, so
|
||||
// any anon-bought credits that hadn't yet been applied locally are
|
||||
// rolled into anon_trials.credits_total before we copy them over to
|
||||
// the new user's tenant_credits.
|
||||
//
|
||||
// Scope: only sweeps the buyer's OWN pending rows. cookieIds is an
|
||||
// optional list of additional anon cookie_ids the caller wants
|
||||
// swept on this buyer's behalf (used by /whoami for the new-signup
|
||||
// case where the just-converted cookie may still have unapplied
|
||||
// purchases). Cap at 5 invoices per sweep + 30-minute lookback so a
|
||||
// degenerate case can't fan out into hundreds of relay calls per
|
||||
// request.
|
||||
export async function sweepUnappliedPurchases({
|
||||
buyerType,
|
||||
buyerId,
|
||||
cookieIds = [],
|
||||
req = null,
|
||||
} = {}) {
|
||||
if (RECAP_MODE !== "multi") return;
|
||||
if (!buyerType && (!cookieIds || cookieIds.length === 0)) return;
|
||||
const base = getRelayBaseURL();
|
||||
if (!base) return; // no relay configured, nothing to sweep against
|
||||
|
||||
const { getDb } = await import("./db.js");
|
||||
const db = getDb();
|
||||
|
||||
// 30-minute lookback. Older unapplied purchases probably failed for
|
||||
// a reason we don't want to keep retrying every page-load (relay
|
||||
// unreachable, invoice expired, etc.). Operator can reconcile
|
||||
// manually if they fire.
|
||||
const since = Date.now() - 30 * 60 * 1000;
|
||||
|
||||
// Build the WHERE clause. Always include the primary buyer; OR in
|
||||
// any extra cookieIds the caller passed.
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
if (buyerType && buyerId) {
|
||||
conditions.push("(buyer_type = ? AND buyer_id = ?)");
|
||||
params.push(buyerType, buyerId);
|
||||
}
|
||||
for (const cid of cookieIds) {
|
||||
if (typeof cid === "string" && cid) {
|
||||
conditions.push("(buyer_type = 'anon' AND buyer_id = ?)");
|
||||
params.push(cid);
|
||||
}
|
||||
}
|
||||
if (conditions.length === 0) return;
|
||||
params.push(since);
|
||||
|
||||
let rows = [];
|
||||
try {
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT invoice_id FROM pending_purchases
|
||||
WHERE (${conditions.join(" OR ")})
|
||||
AND applied_at IS NULL
|
||||
AND created_at >= ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5`,
|
||||
)
|
||||
.all(...params);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[credits/sweep] query failed: ${err?.message || err}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) return;
|
||||
|
||||
for (const { invoice_id: invoiceId } of rows) {
|
||||
try {
|
||||
const r = await fetch(
|
||||
`${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(invoiceId)}`,
|
||||
{
|
||||
headers: relayHeaders({ req }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
},
|
||||
);
|
||||
if (!r.ok) continue;
|
||||
const text = await r.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
const status = body?.result?.status || body?.status || null;
|
||||
if (status === "settled") {
|
||||
await applyPendingPurchase(invoiceId);
|
||||
}
|
||||
} catch (err) {
|
||||
// Best-effort; swallow per-invoice errors so one bad invoice
|
||||
// doesn't block the others (or the page-load).
|
||||
console.warn(
|
||||
`[credits/sweep] invoice ${invoiceId} check failed: ${err?.message || err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
// Multi-tenant SQLite store — single source of truth for users,
|
||||
// sessions, magic-link tokens, subscriptions, tenant credits, and
|
||||
// the library_meta index over /data/history/<userId>/*.json files.
|
||||
//
|
||||
// Created only when RECAP_MODE === 'multi'. In single mode this module
|
||||
// is never imported — `getDb()` would crash trying to require
|
||||
// better-sqlite3 anyway, but the auth-middleware short-circuits before
|
||||
// reaching it. Keep all SQLite access funneled through `getDb()` so
|
||||
// single-mode boots don't touch the native binding at all.
|
||||
//
|
||||
// Forward-only schema. No migration framework — every release is one
|
||||
// `db.exec(SCHEMA_SQL)` at boot. New columns get `ALTER TABLE …`
|
||||
// statements appended below the original CREATEs and guarded with an
|
||||
// existence check; new tables just go in fresh. Rollback is
|
||||
// "checkpoint your /data dir before upgrading."
|
||||
|
||||
import path from "path";
|
||||
|
||||
let dbInstance = null;
|
||||
|
||||
const SCHEMA_SQL = `
|
||||
-- ── users ──────────────────────────────────────────────────────────────
|
||||
-- One row per authenticated end-user. The operator-owner is also a row
|
||||
-- here (is_admin = 1) so per-user library scoping works uniformly.
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_signin_at INTEGER,
|
||||
synthetic_install_id TEXT NOT NULL UNIQUE,
|
||||
keysat_license TEXT,
|
||||
display_name TEXT,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
-- Core-decoupling: the user's subscription tier ("core" | "pro" | "max").
|
||||
-- The Recap Relay is the source of truth (keyed by user-id); this is the
|
||||
-- Recaps-side cache used for feature gating, kept in sync by the operator
|
||||
-- grant flow (which writes here AND POSTs the relay's /relay/user-tier).
|
||||
tier TEXT NOT NULL DEFAULT 'core',
|
||||
-- Captured at first signup for forensic / abuse-investigation use.
|
||||
-- NOT used for auth decisions — just data for the operator to grep
|
||||
-- when an abuse pattern shows up in the admin dashboard.
|
||||
signup_ip TEXT,
|
||||
signup_user_agent TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_signup_ip ON users(signup_ip);
|
||||
|
||||
-- ── sessions ───────────────────────────────────────────────────────────
|
||||
-- Server-side session store so we can revoke individual sessions from
|
||||
-- the dashboard. Cookies carry only the random session id.
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER,
|
||||
user_agent TEXT,
|
||||
ip_address TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||
|
||||
-- ── magic_link_tokens ──────────────────────────────────────────────────
|
||||
-- Plaintext token only ever exists in the outbound email and the
|
||||
-- inbound verify URL — what we persist is the SHA-256 hash. Tokens are
|
||||
-- single-use (used_at NOT NULL = spent) and short-lived (15 min).
|
||||
CREATE TABLE IF NOT EXISTS magic_link_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used_at INTEGER,
|
||||
intent TEXT NOT NULL,
|
||||
-- Request context for abuse investigation. Captured at /auth/request-link
|
||||
-- time, never used for auth decisions — just for the recent-signups admin
|
||||
-- view to surface scripted abuse patterns.
|
||||
request_ip TEXT,
|
||||
request_ua TEXT,
|
||||
-- Anon trial cookie that was present at /auth/request-link time.
|
||||
-- Stored server-side (NOT in the magic-link URL itself — that would
|
||||
-- leak it to anyone who saw the email) so that at /auth/verify we
|
||||
-- can link the trial → user even when the magic-link click lands
|
||||
-- in a different browser / cookie jar than the one that initiated
|
||||
-- the request (Safari Private mode + email-app in-app browser is
|
||||
-- the canonical case). Server-side binding means the cookie ID
|
||||
-- can't be spoofed: an attacker who intercepts the magic link
|
||||
-- still can't change which trial gets linked.
|
||||
trial_cookie_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_email ON magic_link_tokens(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_ip ON magic_link_tokens(request_ip, created_at);
|
||||
|
||||
-- ── subscriptions ──────────────────────────────────────────────────────
|
||||
-- One row per paid period. Multiple rows accumulate as a user renews.
|
||||
-- We don't try to model "the active subscription" — joins to MAX(started_at)
|
||||
-- with status='active' do the job and stay honest about history.
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tier TEXT NOT NULL,
|
||||
started_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
cancelled_at INTEGER,
|
||||
btcpay_invoice_id TEXT,
|
||||
amount_sats INTEGER,
|
||||
status TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_subs_user ON subscriptions(user_id);
|
||||
|
||||
-- ── tenant_credits ─────────────────────────────────────────────────────
|
||||
-- Per-tenant local credit ledger. Cloud users with their OWN keysat
|
||||
-- license bill the relay directly (via the license-keyed pool); this
|
||||
-- table is the source of truth for everyone else — signed-in users on
|
||||
-- the free / cloud-default tier, and family-share tenants on a self-
|
||||
-- hosted multi-tenant Recap.
|
||||
--
|
||||
-- Two buckets per user:
|
||||
-- purchased_balance — a la carte purchases + admin grants + carry-over
|
||||
-- from anon trial conversions. PERMANENT — never
|
||||
-- wiped or refilled.
|
||||
-- replenish_balance — initial signup allowance + periodic refills.
|
||||
-- REFILLED to tenant_default_credits on each
|
||||
-- anniversary period boundary (period set via
|
||||
-- the tenant_credit_replenish_period config).
|
||||
-- Leftover replenish credits at the end of a
|
||||
-- period are FORFEIT (use-it-or-lose-it).
|
||||
--
|
||||
-- Spend order: debit replenish_balance first (it'll refresh anyway),
|
||||
-- then purchased_balance only when the refillable bucket is empty.
|
||||
-- last_replenish_at: epoch-ms of the most recent refill, used to compute
|
||||
-- the next anniversary boundary.
|
||||
CREATE TABLE IF NOT EXISTS tenant_credits (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
purchased_balance INTEGER NOT NULL DEFAULT 0,
|
||||
replenish_balance INTEGER NOT NULL DEFAULT 0,
|
||||
last_replenish_at INTEGER,
|
||||
lifetime_granted INTEGER NOT NULL DEFAULT 0,
|
||||
lifetime_consumed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- ── anon_trials ────────────────────────────────────────────────────────
|
||||
-- Cookie-gated "taste before sign-up" trial. The first time an
|
||||
-- unauthenticated visitor hits /api/process, we issue a recap_anon_trial
|
||||
-- cookie (32-byte random), insert a row here with N credits (set by
|
||||
-- the trial_credits_per_visitor operator config), and let them
|
||||
-- summarize without signing up. After credits_used >= credits_total,
|
||||
-- the UI nudges them to sign up for more.
|
||||
--
|
||||
-- Trial requests forward the OPERATOR's install_id + license to the
|
||||
-- relay, so the operator's credit pool is what actually pays for the
|
||||
-- Gemini call. tenant_credits.balance is irrelevant for trials —
|
||||
-- the credits_total field on this row is the only gate.
|
||||
--
|
||||
-- ip_address rate-limits trial-cookie issuance: trials_per_ip_per_day
|
||||
-- caps how many fresh trial cookies one IP can mint in 24h. Doesn't
|
||||
-- stop sophisticated abuse (IP rotation), but raises the floor for
|
||||
-- scripted laptop attacks and gives the operator a column to grep on.
|
||||
--
|
||||
-- converted_to_user_id is set when the trial holder signs up — links
|
||||
-- the trial summary into their library and lets the operator measure
|
||||
-- the trial → signup conversion rate.
|
||||
CREATE TABLE IF NOT EXISTS anon_trials (
|
||||
cookie_id TEXT PRIMARY KEY,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
credits_total INTEGER NOT NULL,
|
||||
credits_used INTEGER NOT NULL DEFAULT 0,
|
||||
last_used_at INTEGER,
|
||||
converted_to_user_id TEXT REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_anon_trials_ip ON anon_trials(ip_address, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_anon_trials_created ON anon_trials(created_at);
|
||||
|
||||
-- ── pending_purchases ──────────────────────────────────────────────────
|
||||
-- Tracks every credit-purchase invoice initiated through Recap so that
|
||||
-- when the invoice settles (via BTCPay webhook → relay → poll round-
|
||||
-- trip back to us) we know WHO to credit locally.
|
||||
--
|
||||
-- The BTCPay invoice on the relay side credits the OPERATOR's pool —
|
||||
-- the operator paid for the underlying Gemini/etc capacity at the
|
||||
-- relay. Recap's local accounting layer (tenant_credits for signed-in
|
||||
-- users, anon_trials.credits_total for trial cookies) is what gates
|
||||
-- the actual buyer's spend, so we mark this row applied once the
|
||||
-- relevant local balance is incremented. applied_at being non-null is
|
||||
-- the idempotency guard — a poll firing twice doesn't double-credit.
|
||||
--
|
||||
-- buyer_type values:
|
||||
-- "user" → buyer_id is users.id; credits land in tenant_credits
|
||||
-- "anon" → buyer_id is anon_trials.cookie_id; credits land in
|
||||
-- anon_trials.credits_total. If the cookie has since been
|
||||
-- converted to a user (anon_trials.converted_to_user_id),
|
||||
-- credits route to that user's tenant_credits instead.
|
||||
CREATE TABLE IF NOT EXISTS pending_purchases (
|
||||
invoice_id TEXT PRIMARY KEY,
|
||||
buyer_type TEXT NOT NULL,
|
||||
buyer_id TEXT NOT NULL,
|
||||
credits INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
applied_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_purchases_buyer ON pending_purchases(buyer_type, buyer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_purchases_unapplied ON pending_purchases(applied_at) WHERE applied_at IS NULL;
|
||||
|
||||
-- ── pending_signups ────────────────────────────────────────────────────
|
||||
-- Buyer-creates-account flow: when an anon visitor picks Pro / Max
|
||||
-- from the tier signup modal, they enter an email and pay BTCPay
|
||||
-- BEFORE any user account exists. We record the (invoice_id, email,
|
||||
-- policy_slug) here so the poll-settle handler can create the user +
|
||||
-- attach the issued license + send a magic-link email once payment
|
||||
-- lands. applied_at is the idempotency guard — multiple polls after
|
||||
-- settle don't double-create the user.
|
||||
--
|
||||
-- Distinct from pending_purchases (credit-pack buys) because the
|
||||
-- settle effects are completely different: pending_signups creates
|
||||
-- a USER and sends an email; pending_purchases just credits an
|
||||
-- existing buyer's local balance.
|
||||
CREATE TABLE IF NOT EXISTS pending_signups (
|
||||
invoice_id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
policy_slug TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
applied_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_signups_email ON pending_signups(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_signups_unapplied ON pending_signups(applied_at) WHERE applied_at IS NULL;
|
||||
|
||||
-- ── subscription_reminders ─────────────────────────────────────────────
|
||||
-- Dedup ledger for the self-serve expiry-reminder emails. The relay owns
|
||||
-- the subscription expiry; a daily Recaps scan asks it who's expiring and
|
||||
-- emails them. This table guarantees each (user, period, kind) email goes
|
||||
-- out at most once. period_expires_at is the ISO expiry instant the
|
||||
-- reminder is for — when the user renews, expiry changes, so a fresh set
|
||||
-- of reminders re-arms for the new period without re-sending old ones.
|
||||
-- kind is one of 'upcoming_7d', 'upcoming_1d', or 'lapsed'.
|
||||
CREATE TABLE IF NOT EXISTS subscription_reminders (
|
||||
user_id TEXT NOT NULL,
|
||||
period_expires_at TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
sent_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, period_expires_at, kind)
|
||||
);
|
||||
|
||||
-- ── library_meta ───────────────────────────────────────────────────────
|
||||
-- Index over /data/history/<userId>/<sessionId>.json. The summary
|
||||
-- content stays on disk; this table is just for fast listing without
|
||||
-- scanning the filesystem.
|
||||
CREATE TABLE IF NOT EXISTS library_meta (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
video_id TEXT,
|
||||
url TEXT,
|
||||
title TEXT,
|
||||
type TEXT,
|
||||
topic_count INTEGER,
|
||||
segment_count INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
upload_date TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_library_user ON library_meta(user_id, created_at DESC);
|
||||
`;
|
||||
|
||||
// initDb({ dataDir })
|
||||
// Idempotent. Opens /data/recap.db, applies the schema, returns the
|
||||
// connection. Safe to call multiple times — repeat calls return the
|
||||
// existing handle.
|
||||
export async function initDb({ dataDir }) {
|
||||
if (dbInstance) return dbInstance;
|
||||
|
||||
// Lazy import so single-mode never loads the native binding.
|
||||
const { default: Database } = await import("better-sqlite3");
|
||||
|
||||
const dbPath = path.join(dataDir, "recap.db");
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// WAL mode for the obvious reasons: concurrent reads while a write
|
||||
// is in flight, and durable enough for our small write volume
|
||||
// (signups, sessions, library inserts). `synchronous = NORMAL` is
|
||||
// the standard pairing — fsync on checkpoint, not every commit.
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("synchronous = NORMAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
db.exec(SCHEMA_SQL);
|
||||
|
||||
// ── In-place schema migrations ──────────────────────────────────────
|
||||
// SCHEMA_SQL above is the FRESH-INSTALL schema. Existing installs
|
||||
// may have an older shape (e.g. tenant_credits with the legacy
|
||||
// `balance` column). We bring them up to current by introspecting
|
||||
// PRAGMA table_info and ALTER-ing only where needed. Each migration
|
||||
// is idempotent — running boot multiple times is safe.
|
||||
migrateTenantCreditsSchema(db);
|
||||
migrateMagicLinkTokensTrialCookie(db);
|
||||
migrateUsersTier(db);
|
||||
|
||||
dbInstance = db;
|
||||
console.log(`[db] opened ${dbPath} (multi-tenant store)`);
|
||||
return db;
|
||||
}
|
||||
|
||||
// Core-decoupling — add users.tier to existing DBs (fresh installs get it
|
||||
// from SCHEMA_SQL). Idempotent: ALTERs only when the column is missing.
|
||||
function migrateUsersTier(db) {
|
||||
let cols;
|
||||
try {
|
||||
cols = db.prepare("PRAGMA table_info(users)").all();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!cols.some((c) => c.name === "tier")) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN tier TEXT NOT NULL DEFAULT 'core'");
|
||||
console.log("[db] added users.tier column (core-decoupling)");
|
||||
}
|
||||
}
|
||||
|
||||
// v0.2.92 — split the single tenant_credits.balance into two buckets
|
||||
// (purchased + replenish) so we can refill the latter periodically
|
||||
// without wiping the former.
|
||||
function migrateTenantCreditsSchema(db) {
|
||||
let cols;
|
||||
try {
|
||||
cols = db.prepare("PRAGMA table_info(tenant_credits)").all();
|
||||
} catch {
|
||||
return; // table doesn't exist yet (shouldn't happen post-SCHEMA_SQL)
|
||||
}
|
||||
const colNames = new Set(cols.map((c) => c.name));
|
||||
|
||||
// 1. Rename legacy `balance` → `purchased_balance`. Existing balances
|
||||
// were a mix of signup-grant + admin-grant + purchase; treating
|
||||
// them all as "purchased" (permanent) is the safe interpretation
|
||||
// — we'd rather over-preserve than wipe credits on upgrade.
|
||||
if (colNames.has("balance") && !colNames.has("purchased_balance")) {
|
||||
db.exec(
|
||||
"ALTER TABLE tenant_credits RENAME COLUMN balance TO purchased_balance",
|
||||
);
|
||||
console.log(
|
||||
"[db] migrated tenant_credits.balance → tenant_credits.purchased_balance",
|
||||
);
|
||||
colNames.delete("balance");
|
||||
colNames.add("purchased_balance");
|
||||
}
|
||||
|
||||
if (!colNames.has("replenish_balance")) {
|
||||
db.exec(
|
||||
"ALTER TABLE tenant_credits ADD COLUMN replenish_balance INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
console.log("[db] added tenant_credits.replenish_balance");
|
||||
}
|
||||
if (!colNames.has("last_replenish_at")) {
|
||||
db.exec(
|
||||
"ALTER TABLE tenant_credits ADD COLUMN last_replenish_at INTEGER",
|
||||
);
|
||||
console.log("[db] added tenant_credits.last_replenish_at");
|
||||
}
|
||||
}
|
||||
|
||||
// v0.2.104 — add trial_cookie_id to magic_link_tokens so cross-cookie-
|
||||
// jar magic-link clicks (Safari Private → Gmail webview, etc.) still
|
||||
// link the anon trial to the new user at /auth/verify time. Existing
|
||||
// installs get the column added in-place; pre-existing rows just keep
|
||||
// trial_cookie_id = NULL (no linking via the new path, falls back to
|
||||
// the legacy req.cookies path).
|
||||
function migrateMagicLinkTokensTrialCookie(db) {
|
||||
let cols;
|
||||
try {
|
||||
cols = db.prepare("PRAGMA table_info(magic_link_tokens)").all();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const colNames = new Set(cols.map((c) => c.name));
|
||||
if (!colNames.has("trial_cookie_id")) {
|
||||
db.exec(
|
||||
"ALTER TABLE magic_link_tokens ADD COLUMN trial_cookie_id TEXT",
|
||||
);
|
||||
console.log("[db] added magic_link_tokens.trial_cookie_id");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Returns the open handle. Throws if initDb hasn't run — that's a
|
||||
// programming error (some single-mode caller reached a multi-mode
|
||||
// codepath). Callers in multi-mode should assume the handle exists.
|
||||
export function getDb() {
|
||||
if (!dbInstance) {
|
||||
throw new Error(
|
||||
"[db] getDb() called before initDb(); check RECAP_MODE wiring",
|
||||
);
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
// Test/teardown helper. Closes the connection so the next initDb()
|
||||
// call reopens fresh. Not used in production.
|
||||
export function closeDb() {
|
||||
if (dbInstance) {
|
||||
dbInstance.close();
|
||||
dbInstance = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Magic-link email body builder. Returns { subject, text, html } for
|
||||
// nodemailer. Keeps the HTML and text in sync — both carry the same
|
||||
// verifyUrl and the same expiry copy.
|
||||
//
|
||||
// Style is deliberately minimal: one paragraph, one button, no images,
|
||||
// no fancy CSS. Spam filters like simple emails; users skim them and
|
||||
// click the link. Anything fancier risks the email landing in spam,
|
||||
// which is fatal to a magic-link auth flow.
|
||||
|
||||
// renderMagicLinkEmail({ verifyUrl, brandName, expiresInMinutes })
|
||||
// → { subject, text, html }
|
||||
export function renderMagicLinkEmail({
|
||||
verifyUrl,
|
||||
brandName = "Recaps",
|
||||
expiresInMinutes = 15,
|
||||
}) {
|
||||
const subject = `Sign in to ${brandName}`;
|
||||
|
||||
const text = [
|
||||
`Sign in to ${brandName} by opening this link:`,
|
||||
"",
|
||||
verifyUrl,
|
||||
"",
|
||||
`This link expires in ${expiresInMinutes} minutes and can only be used once.`,
|
||||
"",
|
||||
`If you didn't request this, you can safely ignore this email — no one else can use this link without access to your inbox.`,
|
||||
].join("\n");
|
||||
|
||||
// Inline-styled HTML. Most email clients strip <style> blocks, so
|
||||
// everything that needs to look right has to be inline.
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#fafafa;padding:32px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;padding:32px;max-width:90%;">
|
||||
<tr>
|
||||
<td style="font-size:18px;font-weight:600;color:#111;padding-bottom:16px;">
|
||||
Sign in to ${escapeHtml(brandName)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:15px;line-height:1.5;color:#444;padding-bottom:24px;">
|
||||
Click the button below to sign in. This link expires in ${expiresInMinutes} minutes and can only be used once.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:24px;">
|
||||
<a href="${escapeAttr(verifyUrl)}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:6px;">Sign in</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;line-height:1.5;color:#888;padding-bottom:8px;">
|
||||
Or copy and paste this URL into your browser:
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:12px;color:#888;word-break:break-all;padding-bottom:24px;">
|
||||
${escapeHtml(verifyUrl)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:12px;line-height:1.5;color:#888;border-top:1px solid #eee;padding-top:16px;">
|
||||
If you didn't request this, you can safely ignore this email — no one can use this link without access to your inbox.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
// renderSubscriptionReminderEmail({ brandName, tier, expiresAt, daysLeft,
|
||||
// kind, manageUrl }) → { subject, text, html }
|
||||
// kind: 'upcoming_7d' | 'upcoming_1d' | 'lapsed'. Same minimal,
|
||||
// spam-filter-friendly style as the magic-link email: one message, one
|
||||
// button. `expiresAt` is an ISO string or Date; `daysLeft` is a number
|
||||
// (<= 0 means already expired).
|
||||
export function renderSubscriptionReminderEmail({
|
||||
brandName = "Recaps",
|
||||
tier = "pro",
|
||||
expiresAt,
|
||||
daysLeft = 0,
|
||||
kind = "upcoming_7d",
|
||||
manageUrl,
|
||||
}) {
|
||||
const tierLabel = tier === "max" ? "Max" : "Pro";
|
||||
const lapsed = kind === "lapsed";
|
||||
let when;
|
||||
if (lapsed) when = "has expired";
|
||||
else if (daysLeft <= 1) when = "expires tomorrow";
|
||||
else when = `expires in ${daysLeft} days`;
|
||||
|
||||
let expiryDateStr = "";
|
||||
try {
|
||||
const d = expiresAt instanceof Date ? expiresAt : new Date(expiresAt);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
expiryDateStr = d.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const subject = lapsed
|
||||
? `Your ${brandName} ${tierLabel} plan has expired`
|
||||
: `Your ${brandName} ${tierLabel} plan ${when}`;
|
||||
|
||||
const lead = lapsed
|
||||
? `Your ${brandName} ${tierLabel} plan has expired${expiryDateStr ? ` (${expiryDateStr})` : ""}, so your account is back on the free Core tier. Renew anytime to restore ${tierLabel} — it's a one-time payment, no auto-charges.`
|
||||
: `Your ${brandName} ${tierLabel} plan ${when}${expiryDateStr ? ` (${expiryDateStr})` : ""}. Renew to keep your ${tierLabel} features — it's a one-time payment for another period, no auto-charges.`;
|
||||
|
||||
const cta = lapsed ? `Renew ${tierLabel}` : `Renew now`;
|
||||
|
||||
const text = [
|
||||
lapsed
|
||||
? `Your ${brandName} ${tierLabel} plan has expired.`
|
||||
: `Your ${brandName} ${tierLabel} plan ${when}.`,
|
||||
"",
|
||||
lead,
|
||||
"",
|
||||
`${cta}: ${manageUrl}`,
|
||||
"",
|
||||
`You're receiving this because you have a ${brandName} account. Prepaid plans never auto-renew — you're only charged when you choose to.`,
|
||||
].join("\n");
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#fafafa;padding:32px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;padding:32px;max-width:90%;">
|
||||
<tr>
|
||||
<td style="font-size:18px;font-weight:600;color:#111;padding-bottom:16px;">
|
||||
Your ${escapeHtml(brandName)} ${escapeHtml(tierLabel)} plan ${escapeHtml(lapsed ? "has expired" : when)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:15px;line-height:1.5;color:#444;padding-bottom:24px;">
|
||||
${escapeHtml(lead)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:24px;">
|
||||
<a href="${escapeAttr(manageUrl)}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:6px;">${escapeHtml(cta)}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:12px;line-height:1.5;color:#888;border-top:1px solid #eee;padding-top:16px;">
|
||||
You're receiving this because you have a ${escapeHtml(brandName)} account. Prepaid plans never auto-renew — you're only charged when you choose to.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return escapeHtml(s);
|
||||
}
|
||||
+64
-10
@@ -9,10 +9,13 @@ import { formatTime } from "./util.js";
|
||||
// numbers are operational data, not configuration. Update when Google
|
||||
// changes published rates.
|
||||
export const PRICING = {
|
||||
"gemini-3-flash-preview": { input: 0.50, output: 3.00, thinking: 3.00 },
|
||||
"gemini-3-pro-preview": { input: 2.00, output: 12.00, thinking: 12.00 },
|
||||
// The five Gemini models we support. Verified against Google's
|
||||
// official docs on 2026-05-12. Retired/never-existed IDs omitted.
|
||||
"gemini-3.1-pro-preview": { input: 2.00, output: 12.00, thinking: 12.00 },
|
||||
"gemini-2.5-pro": { input: 1.25, output: 10.00, thinking: 10.00 },
|
||||
"gemini-3-flash-preview": { input: 0.50, output: 3.00, thinking: 3.00 },
|
||||
"gemini-2.5-flash": { input: 0.15, output: 0.60, thinking: 0.60 },
|
||||
"gemini-3.1-flash-lite": { input: 0.10, output: 0.40, thinking: 0.40 },
|
||||
// Fallback for unknown / future models — better an estimate than nothing.
|
||||
"default": { input: 1.00, output: 5.00, thinking: 5.00 },
|
||||
};
|
||||
@@ -46,16 +49,67 @@ export function calcCost(modelName, usage) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Section-count target by VIDEO duration ─────────────────────────────────
|
||||
// Mirrors recap-relay's computePerWindowTarget() (server/chunked-analyze.js).
|
||||
// Operator-tunable on the relay; baked into code defaults here on the
|
||||
// Recap-app direct path. The defaults match the relay's defaults so
|
||||
// segmentation density is consistent across both pipelines.
|
||||
//
|
||||
// Buckets are TOTAL video duration in minutes:
|
||||
// <30 → 6 sections / 30-60 → 8 / 60-90 → 9 / 90-120 → 10
|
||||
// 120-150 → 11 / 150-180 → 12 / >=180 → 12
|
||||
// Per-window target = total_target × window_sec / total_audio_sec
|
||||
// (clamped to ≥1 for single-shot runs).
|
||||
function pickTotalSectionsTarget(totalAudioSec) {
|
||||
const m = (totalAudioSec || 0) / 60;
|
||||
if (m < 30) return 6;
|
||||
if (m < 60) return 8;
|
||||
if (m < 90) return 9;
|
||||
if (m < 120) return 10;
|
||||
if (m < 150) return 11;
|
||||
if (m < 180) return 12;
|
||||
return 12;
|
||||
}
|
||||
function formatTargetSectionsLabel(avg) {
|
||||
if (avg <= 1.2) return "1 section";
|
||||
const lo = Math.max(1, Math.floor(avg));
|
||||
const hi = Math.max(lo, Math.ceil(avg));
|
||||
if (lo === hi) return "around " + lo + " sections";
|
||||
return lo + "–" + hi + " sections";
|
||||
}
|
||||
|
||||
// ── Topic-analysis prompt builder ───────────────────────────────────────────
|
||||
// Takes the parsed transcript entries and builds the JSON-output prompt
|
||||
// fed to the analysis model. Indices in the response are positional into
|
||||
// the same `entries` array — the caller relies on that contract.
|
||||
export function buildAnalysisPrompt(entries) {
|
||||
// Takes the parsed transcript entries for a WINDOW and builds the
|
||||
// JSON-output prompt fed to the analysis model. Indices in the response
|
||||
// are positional into the same window-entries array — the caller relies
|
||||
// on that contract.
|
||||
//
|
||||
// `opts.totalAudioSec` is the FULL audio duration (not just this window),
|
||||
// used to scale the section-count target via the per-video-duration table
|
||||
// above. When omitted, falls back to deriving from the windowEntries
|
||||
// themselves (legacy callers / unit tests / single-shot path).
|
||||
export function buildAnalysisPrompt(entries, opts = {}) {
|
||||
const numbered = entries
|
||||
.map((e, i) => `[${i}] (${formatTime(e.offset)}) ${e.text}`)
|
||||
.join("\n");
|
||||
|
||||
return `You are analyzing a video transcript. Your job is to identify natural topic boundaries and group the transcript into discussion-based sections.
|
||||
// Window length in minutes (this window's own transcript span).
|
||||
const windowSec = entries.length > 1
|
||||
? (entries[entries.length - 1].offset || 0) - (entries[0].offset || 0)
|
||||
: 0;
|
||||
const windowMin = Math.max(1, Math.round(windowSec / 60));
|
||||
const maxIndex = Math.max(0, entries.length - 1);
|
||||
|
||||
// Total audio duration drives the per-video-duration target picker.
|
||||
// If the caller didn't supply it, assume this is a single-shot run
|
||||
// and the window IS the whole audio.
|
||||
const totalAudioSec = opts.totalAudioSec || windowSec || 60;
|
||||
const totalTarget = pickTotalSectionsTarget(totalAudioSec);
|
||||
const numWindows = Math.max(1, totalAudioSec / Math.max(60, windowSec || 60));
|
||||
const avgPerWindow = totalTarget / numWindows;
|
||||
const targetSections = formatTargetSectionsLabel(avgPerWindow);
|
||||
|
||||
return `You are analyzing a ~${windowMin}-minute section of a longer transcript. Your job is to identify natural topic boundaries and group the transcript into discussion-based sections — aim for ${targetSections}.
|
||||
|
||||
TRANSCRIPT (each line is numbered with a timestamp):
|
||||
${numbered}
|
||||
@@ -67,13 +121,13 @@ INSTRUCTIONS:
|
||||
4. For each section, write:
|
||||
- A short, specific topic title (3-8 words)
|
||||
- A 1-3 sentence summary of what's discussed
|
||||
- The start and end segment indices (inclusive)
|
||||
- The start and end segment indices (inclusive), counted as the bracketed [N] number at the start of each transcript line above.
|
||||
|
||||
IMPORTANT:
|
||||
- Sections must be chronological and non-overlapping.
|
||||
- Every segment index from 0 to ${entries.length - 1} must belong to exactly one section.
|
||||
- Every segment index from 0 to ${maxIndex} must belong to exactly one section.
|
||||
- startIndex of section N+1 must equal endIndex of section N plus 1.
|
||||
- Create as many or as few sections as the content naturally requires.
|
||||
- Create as many or as few sections as the content naturally requires — but lean toward broad, substantive topics rather than minute-by-minute breakdowns. A natural topic that spans several minutes of dialogue should be one section, not several.
|
||||
- Titles should be descriptive and specific, not generic like "Introduction" unless it truly is one.
|
||||
|
||||
Respond with ONLY valid JSON in this exact format, no other text:
|
||||
|
||||
+472
-86
@@ -1,33 +1,136 @@
|
||||
// History storage + routes. Sessions are written as one JSON file per
|
||||
// summary in /data/history/<id>.json. Folder structure / ordering lives
|
||||
// in a sidecar `_meta.json`.
|
||||
// History storage + routes. Per-user-scoped under /data/history/<scope>/.
|
||||
//
|
||||
// Module-private state: just the historyDir path, set by initHistory().
|
||||
// The DELETE route needs to add the deleted videoId to the skip list
|
||||
// (so subscriptions don't re-queue it) — that's a cross-module concern,
|
||||
// so it's injected as a callback by setupHistoryRoutes.
|
||||
// "Scope" is:
|
||||
// - single mode: "owner" (always)
|
||||
// - multi mode signed-in user: "<user_id>"
|
||||
// - multi mode anonymous trial: "anon/<trial_cookie_id>"
|
||||
//
|
||||
// Each scope has its own folder with one *.json file per summary and a
|
||||
// `_meta.json` for the folder/ordering UI. Scope isolation is enforced
|
||||
// here at the path level — handlers in this file refuse to read across
|
||||
// scopes, period. The auth middleware populates req.userId; we derive
|
||||
// the scope via scopeForRequest(req) and never trust raw URL input.
|
||||
//
|
||||
// On a brand-new install nothing exists. Single-mode installs created
|
||||
// before 0.2.77 wrote files flat to /data/history/*.json; the migration
|
||||
// hook (see migrateLegacyLibrary below) moves those into the "owner"
|
||||
// scope on first multi-mode boot.
|
||||
//
|
||||
// Module-private state: historyDir (the root path). All per-scope
|
||||
// paths are derived per-call so adding a new user doesn't need a
|
||||
// re-init.
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
let historyDir = null;
|
||||
let metaPath = null;
|
||||
|
||||
// ── Initialization ──────────────────────────────────────────────────────────
|
||||
// Call once at boot. Creates the directory and stores the path. Idempotent.
|
||||
export async function initHistory({ dataDir }) {
|
||||
// Call once at boot. Creates the root directory and stores the path.
|
||||
// In single mode also ensures /data/history/owner exists so the
|
||||
// owner-scope writes don't race on first-summary mkdir.
|
||||
export async function initHistory({ dataDir, mode = "single" }) {
|
||||
historyDir = path.join(dataDir, "history");
|
||||
metaPath = path.join(historyDir, "_meta.json");
|
||||
await fs.mkdir(historyDir, { recursive: true }).catch(() => {});
|
||||
if (mode === "single") {
|
||||
await fs.mkdir(ownerScopeDir(), { recursive: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scope helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
// Files that live at the root of /data/history/ (not inside a per-user
|
||||
// scope) — subscription state, skip lists, etc. Filtered out when
|
||||
// listing sessions so they don't appear as phantom library items.
|
||||
// Files that live inside a scope dir but are NOT session records:
|
||||
// the folder/order meta + the per-scope subscription state (moved here by
|
||||
// the 0.2.147 migration). They must be filtered out of every place that
|
||||
// lists `.json` files as sessions, or they show up as phantom "Invalid
|
||||
// Date · undefined topics" library entries.
|
||||
export const ROOT_SIDECARS = new Set([
|
||||
"_meta.json",
|
||||
"subscriptions.json",
|
||||
"skip-list.json",
|
||||
"seen-list.json",
|
||||
"auto-queue.json",
|
||||
]);
|
||||
|
||||
// Sanitize a user-supplied scope component so it can't escape the
|
||||
// history root via path traversal. Allows the alphabet that user_ids
|
||||
// and trial cookie_ids use (base64url + hex chars + the literal "anon"
|
||||
// and "owner" prefixes). Anything else → throw.
|
||||
function safeComponent(s) {
|
||||
if (typeof s !== "string" || !s) throw new Error("invalid_scope_component");
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(s)) throw new Error("invalid_scope_component");
|
||||
return s;
|
||||
}
|
||||
|
||||
// scopeForRequest(req) — single string identifying the writer/reader
|
||||
// of a library. Used as a subpath under /data/history/. Throws if the
|
||||
// request has no usable identity (caller should 401 in that case).
|
||||
//
|
||||
// Returned strings:
|
||||
// "owner" — single mode, OR the multi-mode admin (so
|
||||
// a multi→single mode flip preserves the
|
||||
// operator's library at the same path)
|
||||
// "<user_id>" — multi mode non-admin signed-in user
|
||||
// "anon/<cookie_id>" — multi mode anonymous-trial cookie
|
||||
//
|
||||
// Why admin → "owner": before v0.2.91 we renamed /data/history/owner/
|
||||
// → /data/history/<admin_user_id>/ on first multi-mode signup, which
|
||||
// made switching back to single mode hide the operator's library
|
||||
// (single mode reads "owner"). Keeping admin's scope at "owner"
|
||||
// regardless of mode makes mode-switching lossless.
|
||||
export function scopeForRequest(req) {
|
||||
if (req.recapMode !== "multi") return "owner";
|
||||
if (req.user && req.user.is_admin) return "owner";
|
||||
if (req.user && req.user.id) return safeComponent(req.user.id);
|
||||
if (typeof req.userId === "string" && req.userId.startsWith("anon:")) {
|
||||
return `anon/${safeComponent(req.userId.slice(5))}`;
|
||||
}
|
||||
if (req.userId === "owner") return "owner"; // pre-multi-mode legacy shim
|
||||
throw new Error("no_scope");
|
||||
}
|
||||
|
||||
function scopeDir(scope) {
|
||||
// `scope` may contain a slash for the "anon/<id>" case — split into
|
||||
// segments so path.join doesn't treat it as one component (and so
|
||||
// safeComponent enforcement covers each piece).
|
||||
const parts = scope.split("/").map(safeComponent);
|
||||
return path.join(historyDir, ...parts);
|
||||
}
|
||||
|
||||
function ownerScopeDir() {
|
||||
return path.join(historyDir, "owner");
|
||||
}
|
||||
|
||||
function metaPathFor(scope) {
|
||||
return path.join(scopeDir(scope), "_meta.json");
|
||||
}
|
||||
|
||||
// ── Storage ─────────────────────────────────────────────────────────────────
|
||||
// saveToHistory persists a completed summary. Returns the generated id.
|
||||
// Used by /api/process. The id encodes the timestamp + a content hint
|
||||
// (videoId for YouTube, base64-truncated guid/url for podcasts) so files
|
||||
// sort chronologically by name.
|
||||
export async function saveToHistory(videoId, url, title, chunks, entries, logs, uploadDate, type) {
|
||||
const idSuffix = type === "podcast"
|
||||
// Caller (the /api/process handler) is responsible for passing the
|
||||
// right scope — derived via scopeForRequest(req) up the call stack.
|
||||
//
|
||||
// The id encodes the timestamp + a content hint (videoId for YouTube,
|
||||
// base64-truncated guid/url for podcasts) so files sort chronologically
|
||||
// by name.
|
||||
export async function saveToHistory(
|
||||
scope,
|
||||
videoId,
|
||||
url,
|
||||
title,
|
||||
chunks,
|
||||
entries,
|
||||
logs,
|
||||
uploadDate,
|
||||
type,
|
||||
speakers = null,
|
||||
speakerNames = null,
|
||||
) {
|
||||
const idSuffix =
|
||||
type === "podcast"
|
||||
? Buffer.from(videoId).toString("base64url").slice(0, 16)
|
||||
: videoId;
|
||||
const id = `${Date.now()}-${idSuffix}`;
|
||||
@@ -44,101 +147,352 @@ export async function saveToHistory(videoId, url, title, chunks, entries, logs,
|
||||
chunks,
|
||||
entries,
|
||||
logs,
|
||||
// Phase 1E — speaker legend summary keyed by global speaker ID
|
||||
// (Speaker_A, Speaker_B, ...). Each chunk's entries also carry
|
||||
// `.speaker` and `.speaker_confidence` fields inline. Null when
|
||||
// diarization wasn't available (older relay, off, or no
|
||||
// fingerprints collected). Persisting at the record level lets
|
||||
// the library card show "2 speakers" without scanning entries.
|
||||
speakers: speakers || null,
|
||||
// Phase 2 — inferred speaker names from the relay's post-cluster
|
||||
// polish pass. Map { Speaker_A: "Matt Hill", ... } with null
|
||||
// values for unidentified speakers. Reopening a saved session
|
||||
// restores names alongside the cluster IDs.
|
||||
speakerNames: speakerNames || null,
|
||||
};
|
||||
await fs.writeFile(path.join(historyDir, `${id}.json`), JSON.stringify(record));
|
||||
const dir = scopeDir(scope);
|
||||
await fs.mkdir(dir, { recursive: true }).catch(() => {});
|
||||
await fs.writeFile(path.join(dir, `${id}.json`), JSON.stringify(record));
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── Meta ────────────────────────────────────────────────────────────────────
|
||||
// `_meta.json` shape: { folders: [{ id, name, order, collapsed,
|
||||
// items: [sessionId, ...] }], uncategorized: [sessionId, ...] }
|
||||
export async function loadMeta() {
|
||||
// Each scope has its own `_meta.json` for folder/ordering UI state.
|
||||
// New scope = empty meta on read (no file yet).
|
||||
export async function loadMeta(scope) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(metaPath, "utf-8"));
|
||||
return JSON.parse(await fs.readFile(metaPathFor(scope), "utf-8"));
|
||||
} catch {
|
||||
return { folders: [], uncategorized: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMeta(meta) {
|
||||
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
|
||||
export async function saveMeta(scope, meta) {
|
||||
const dir = scopeDir(scope);
|
||||
await fs.mkdir(dir, { recursive: true }).catch(() => {});
|
||||
await fs.writeFile(metaPathFor(scope), JSON.stringify(meta, null, 2));
|
||||
}
|
||||
|
||||
// getHistoryDir() — root /data/history/. Some callers (subscriptions,
|
||||
// skip-list, etc.) write sidecar files here that aren't scoped.
|
||||
export function getHistoryDir() {
|
||||
return historyDir;
|
||||
}
|
||||
|
||||
// ── Routes ──────────────────────────────────────────────────────────────────
|
||||
// Pass `addToSkipList` so the DELETE route can suppress re-queueing of
|
||||
// videos the user has explicitly removed. Decoupled from subscriptions
|
||||
// to keep this module standalone.
|
||||
export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
// Get all history: sessions + folder structure
|
||||
app.get("/api/history", async (req, res) => {
|
||||
// getScopeHistoryDir(scope) — the per-scope directory. Used by handlers
|
||||
// that need raw filesystem access (e.g. delete).
|
||||
export function getScopeHistoryDir(scope) {
|
||||
return scopeDir(scope);
|
||||
}
|
||||
|
||||
// ── Audio-first ("walking mode") TTS cache helpers ──────────────────────────
|
||||
// Per-topic synthesized summary clips live alongside the session JSON in
|
||||
// a sibling folder: /data/history/<scope>/<id>-audio/topic-<i>.mp3. Same
|
||||
// scope-isolation guarantees as the session record (safeFilename guards
|
||||
// the id; scopeDir guards the scope).
|
||||
|
||||
// Directory holding a session's cached summary-audio clips.
|
||||
export function sessionAudioDir(scope, id) {
|
||||
return path.join(scopeDir(scope), `${safeFilename(id)}-audio`);
|
||||
}
|
||||
|
||||
// Load a full session record by id within a scope. Returns null if it
|
||||
// doesn't exist (or can't be parsed) — callers 404 on null.
|
||||
export async function loadSession(scope, id) {
|
||||
try {
|
||||
const files = await fs.readdir(historyDir);
|
||||
const raw = await fs.readFile(
|
||||
path.join(scopeDir(scope), `${safeFilename(id)}.json`),
|
||||
"utf-8",
|
||||
);
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Shallow-merge `patch` into a session record on disk (e.g. to stamp
|
||||
// `summaryAudio` availability). No-op-safe: returns null if the record
|
||||
// is missing rather than throwing.
|
||||
export async function patchSession(scope, id, patch) {
|
||||
const file = path.join(scopeDir(scope), `${safeFilename(id)}.json`);
|
||||
let rec;
|
||||
try {
|
||||
rec = JSON.parse(await fs.readFile(file, "utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const merged = { ...rec, ...patch };
|
||||
await fs.writeFile(file, JSON.stringify(merged));
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── Legacy library migration (single → multi) ───────────────────────────────
|
||||
// Pre-0.2.77 single-mode installs wrote summaries flat to
|
||||
// /data/history/*.json with a single _meta.json. On first boot in
|
||||
// multi mode we move all of that into /data/history/owner/ so the
|
||||
// operator's library is accessible under the "owner" scope. After the
|
||||
// first real user signs up (is_admin=1), auth-routes.js renames that
|
||||
// folder to the user's actual id so they own their original library.
|
||||
//
|
||||
// Idempotent — writes a sentinel after the first migration. Safe to
|
||||
// call on every boot; no-op if there's nothing flat to move.
|
||||
export async function migrateLegacyLibrary() {
|
||||
const sentinel = path.join(historyDir, ".migrated_to_multi");
|
||||
try {
|
||||
await fs.access(sentinel);
|
||||
return { migrated: 0, skipped: "already_migrated" };
|
||||
} catch {}
|
||||
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.readdir(historyDir);
|
||||
} catch {
|
||||
return { migrated: 0, skipped: "no_history_dir" };
|
||||
}
|
||||
|
||||
const flatFiles = entries.filter(
|
||||
(f) => f.endsWith(".json") && !ROOT_SIDECARS.has(f),
|
||||
);
|
||||
if (flatFiles.length === 0 && !entries.includes("_meta.json")) {
|
||||
// Truly empty. Write the sentinel so future boots don't keep
|
||||
// checking, but flag this as a non-migration.
|
||||
await fs.writeFile(sentinel, new Date().toISOString());
|
||||
return { migrated: 0, skipped: "empty_legacy_library" };
|
||||
}
|
||||
|
||||
const target = ownerScopeDir();
|
||||
await fs.mkdir(target, { recursive: true }).catch(() => {});
|
||||
|
||||
let moved = 0;
|
||||
for (const f of flatFiles) {
|
||||
try {
|
||||
await fs.rename(
|
||||
path.join(historyDir, f),
|
||||
path.join(target, f),
|
||||
);
|
||||
moved += 1;
|
||||
} catch (err) {
|
||||
console.warn(`[history] failed to migrate ${f}:`, err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
// Move _meta.json too if it exists at the root. Folder/ordering state
|
||||
// belongs to the same library.
|
||||
try {
|
||||
await fs.rename(
|
||||
path.join(historyDir, "_meta.json"),
|
||||
path.join(target, "_meta.json"),
|
||||
);
|
||||
} catch {} // no _meta.json is fine
|
||||
|
||||
await fs.writeFile(sentinel, new Date().toISOString());
|
||||
console.log(
|
||||
`[history] migrated ${moved} legacy session(s) to /data/history/owner/`,
|
||||
);
|
||||
return { migrated: moved };
|
||||
}
|
||||
|
||||
// reclaimAdminLibraryToOwner({ db }) — one-time fixup for installs
|
||||
// upgraded from <0.2.91 where the first admin's library got renamed
|
||||
// from /data/history/owner/ → /data/history/<admin_user_id>/ on their
|
||||
// signup. With the new admin-scope-is-always-owner rule (above), we
|
||||
// need that library back under "owner" so multi-mode admin reads it
|
||||
// AND a future single-mode flip can still find it. Idempotent — runs
|
||||
// the rename only if BOTH (a) an admin user exists in SQLite AND
|
||||
// (b) /data/history/<admin_user_id>/ exists AND (c) /data/history/owner/
|
||||
// does NOT already exist. Otherwise no-op.
|
||||
//
|
||||
// Pass in the better-sqlite3 db handle from db.js — we don't import
|
||||
// here to avoid a dep cycle (db.js is multi-mode only, history.js is
|
||||
// loaded in single mode too).
|
||||
export async function reclaimAdminLibraryToOwner({ db }) {
|
||||
if (!db) return { reclaimed: false, reason: "no_db" };
|
||||
let admin;
|
||||
try {
|
||||
admin = db
|
||||
.prepare(
|
||||
"SELECT id, email FROM users WHERE is_admin = 1 ORDER BY created_at ASC LIMIT 1",
|
||||
)
|
||||
.get();
|
||||
} catch {
|
||||
return { reclaimed: false, reason: "no_users_table" };
|
||||
}
|
||||
if (!admin) return { reclaimed: false, reason: "no_admin" };
|
||||
|
||||
const ownerDir = path.join(historyDir, "owner");
|
||||
const adminDir = path.join(historyDir, safeComponent(admin.id));
|
||||
try {
|
||||
await fs.access(ownerDir);
|
||||
// /data/history/owner/ already exists → either a fresh install or
|
||||
// the fixup already ran. Either way, do nothing.
|
||||
return { reclaimed: false, reason: "owner_already_exists" };
|
||||
} catch {}
|
||||
try {
|
||||
await fs.access(adminDir);
|
||||
} catch {
|
||||
return { reclaimed: false, reason: "admin_dir_missing" };
|
||||
}
|
||||
await fs.rename(adminDir, ownerDir);
|
||||
console.log(
|
||||
`[history] reclaimed admin library: /data/history/${admin.id}/ → /data/history/owner/`,
|
||||
);
|
||||
return { reclaimed: true, admin_id: admin.id, email: admin.email };
|
||||
}
|
||||
|
||||
// renameScopeDir(fromScope, toScope) — atomic rename of a per-scope
|
||||
// folder. Used when:
|
||||
// - the first multi-mode signup claims the "owner" legacy library
|
||||
// (auth-routes.js calls this with fromScope="owner", toScope=user.id)
|
||||
// - an anonymous trial converts to a real user (auth-routes.js,
|
||||
// fromScope="anon/<cookie_id>", toScope=user.id)
|
||||
//
|
||||
// If `fromScope` doesn't exist, no-op (returns false). If `toScope`
|
||||
// already exists, we don't clobber — the caller has to merge manually
|
||||
// (which currently only matters in edge cases, since fresh user ids
|
||||
// are uuids that won't collide). Returns true on actual rename.
|
||||
export async function renameScopeDir(fromScope, toScope) {
|
||||
const from = scopeDir(fromScope);
|
||||
const to = scopeDir(toScope);
|
||||
try {
|
||||
await fs.access(from);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await fs.access(to);
|
||||
console.warn(
|
||||
`[history] renameScopeDir: ${toScope} already exists; refusing to clobber. Leaving ${fromScope} in place for manual reconciliation.`,
|
||||
);
|
||||
return false;
|
||||
} catch {}
|
||||
// Ensure parent of `to` exists (for the "anon/<id>" case the parent
|
||||
// is /data/history/anon/, which won't be there in fresh installs).
|
||||
await fs.mkdir(path.dirname(to), { recursive: true }).catch(() => {});
|
||||
await fs.rename(from, to);
|
||||
console.log(`[history] renamed scope ${fromScope} → ${toScope}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Routes ──────────────────────────────────────────────────────────────────
|
||||
// All routes are scoped to req — they read scopeForRequest(req) and
|
||||
// refuse to operate outside that scope. No request body or URL param
|
||||
// can reference another user's library.
|
||||
//
|
||||
// `addToSkipList(scope, videoId)` is injected so the DELETE route can
|
||||
// suppress re-queueing of a subscription video the user explicitly removed.
|
||||
// It's scope-keyed (./subscriptions.js): the skip applies to the same
|
||||
// scope's subscription store.
|
||||
export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
function requireScope(req, res) {
|
||||
try {
|
||||
return scopeForRequest(req);
|
||||
} catch {
|
||||
res.status(401).json({ error: "auth_required" });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all history: sessions + folder structure for THIS user.
|
||||
app.get("/api/history", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const dir = scopeDir(scope);
|
||||
let files = [];
|
||||
try {
|
||||
files = await fs.readdir(dir);
|
||||
} catch {
|
||||
files = []; // no library yet — render an empty state
|
||||
}
|
||||
const sessionsMap = {};
|
||||
// Skip the meta + state files; everything else is a session.
|
||||
for (const file of files.filter(f =>
|
||||
f.endsWith(".json") &&
|
||||
!f.startsWith("_") &&
|
||||
f !== "subscriptions.json" &&
|
||||
f !== "skip-list.json" &&
|
||||
f !== "seen-list.json" &&
|
||||
f !== "auto-queue.json"
|
||||
for (const file of files.filter(
|
||||
(f) =>
|
||||
f.endsWith(".json") && !f.startsWith("_") && !ROOT_SIDECARS.has(f),
|
||||
)) {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(historyDir, file), "utf-8");
|
||||
const raw = await fs.readFile(path.join(dir, file), "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
sessionsMap[data.id] = {
|
||||
id: data.id, videoId: data.videoId, url: data.url,
|
||||
title: data.title, topicCount: data.topicCount,
|
||||
id: data.id,
|
||||
videoId: data.videoId,
|
||||
url: data.url,
|
||||
title: data.title,
|
||||
topicCount: data.topicCount,
|
||||
type: data.type || "youtube",
|
||||
segmentCount: data.segmentCount, createdAt: data.createdAt,
|
||||
segmentCount: data.segmentCount,
|
||||
createdAt: data.createdAt,
|
||||
uploadDate: data.uploadDate || "",
|
||||
};
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const meta = await loadMeta();
|
||||
const meta = await loadMeta(scope);
|
||||
|
||||
// Clean up: remove references to deleted sessions
|
||||
for (const folder of meta.folders) {
|
||||
folder.items = folder.items.filter(id => sessionsMap[id]);
|
||||
folder.items = folder.items.filter((id) => sessionsMap[id]);
|
||||
}
|
||||
meta.uncategorized = meta.uncategorized.filter(id => sessionsMap[id]);
|
||||
meta.uncategorized = meta.uncategorized.filter((id) => sessionsMap[id]);
|
||||
|
||||
// Add any sessions not in meta (newly created)
|
||||
const allReferenced = new Set([
|
||||
...meta.uncategorized,
|
||||
...meta.folders.flatMap(f => f.items),
|
||||
...meta.folders.flatMap((f) => f.items),
|
||||
]);
|
||||
const allIds = Object.keys(sessionsMap);
|
||||
const orphans = allIds.filter(id => !allReferenced.has(id))
|
||||
.sort((a, b) => new Date(sessionsMap[b].createdAt) - new Date(sessionsMap[a].createdAt));
|
||||
const orphans = allIds
|
||||
.filter((id) => !allReferenced.has(id))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(sessionsMap[b].createdAt) -
|
||||
new Date(sessionsMap[a].createdAt),
|
||||
);
|
||||
meta.uncategorized = [...orphans, ...meta.uncategorized];
|
||||
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json({ sessions: sessionsMap, meta });
|
||||
} catch (err) {
|
||||
res.json({ sessions: {}, meta: { folders: [], uncategorized: [] } });
|
||||
res.json({
|
||||
sessions: {},
|
||||
meta: { folders: [], uncategorized: [] },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get a single session (full data)
|
||||
// Get a single session (full data) — scoped to current user.
|
||||
app.get("/api/history/:id", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(historyDir, `${req.params.id}.json`), "utf-8");
|
||||
const raw = await fs.readFile(
|
||||
path.join(scopeDir(scope), `${safeFilename(req.params.id)}.json`),
|
||||
"utf-8",
|
||||
);
|
||||
res.json(JSON.parse(raw));
|
||||
} catch {
|
||||
res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
});
|
||||
|
||||
// Rename a session title
|
||||
// Rename a session title — scoped.
|
||||
app.put("/api/history/:id/title", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const filePath = path.join(historyDir, `${req.params.id}.json`);
|
||||
const filePath = path.join(
|
||||
scopeDir(scope),
|
||||
`${safeFilename(req.params.id)}.json`,
|
||||
);
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
data.title = req.body.title || data.title;
|
||||
@@ -149,11 +503,16 @@ export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a session — also adds the videoId to the skip list so any
|
||||
// subscriptions don't re-queue it.
|
||||
// Delete a session — scoped. Also adds the videoId to the (global)
|
||||
// skip list so subscriptions don't re-queue it.
|
||||
app.delete("/api/history/:id", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const filePath = path.join(historyDir, `${req.params.id}.json`);
|
||||
const filePath = path.join(
|
||||
scopeDir(scope),
|
||||
`${safeFilename(req.params.id)}.json`,
|
||||
);
|
||||
let videoId = null;
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
@@ -163,26 +522,30 @@ export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
await fs.unlink(filePath);
|
||||
|
||||
if (videoId && typeof addToSkipList === "function") {
|
||||
await addToSkipList(videoId);
|
||||
await addToSkipList(scope, videoId);
|
||||
}
|
||||
|
||||
const meta = await loadMeta();
|
||||
meta.uncategorized = meta.uncategorized.filter(id => id !== req.params.id);
|
||||
const meta = await loadMeta(scope);
|
||||
meta.uncategorized = meta.uncategorized.filter(
|
||||
(id) => id !== req.params.id,
|
||||
);
|
||||
for (const folder of meta.folders) {
|
||||
folder.items = folder.items.filter(id => id !== req.params.id);
|
||||
folder.items = folder.items.filter((id) => id !== req.params.id);
|
||||
}
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json({ ok: true });
|
||||
} catch {
|
||||
res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
});
|
||||
|
||||
// Update meta (folders, ordering) — the frontend sends the full structure
|
||||
// Update meta (folders, ordering) — scoped.
|
||||
app.put("/api/history/meta", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const meta = req.body;
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -190,11 +553,18 @@ export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
});
|
||||
|
||||
app.post("/api/history/folders", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const meta = await loadMeta();
|
||||
const folder = { id: `folder-${Date.now()}`, name: req.body.name || "New Folder", collapsed: false, items: [] };
|
||||
const meta = await loadMeta(scope);
|
||||
const folder = {
|
||||
id: `folder-${Date.now()}`,
|
||||
name: req.body.name || "New Folder",
|
||||
collapsed: false,
|
||||
items: [],
|
||||
};
|
||||
meta.folders.push(folder);
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json(folder);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -202,12 +572,14 @@ export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
});
|
||||
|
||||
app.put("/api/history/folders/:id", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const meta = await loadMeta();
|
||||
const folder = meta.folders.find(f => f.id === req.params.id);
|
||||
const meta = await loadMeta(scope);
|
||||
const folder = meta.folders.find((f) => f.id === req.params.id);
|
||||
if (!folder) return res.status(404).json({ error: "Folder not found" });
|
||||
folder.name = req.body.name || folder.name;
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json(folder);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -215,48 +587,52 @@ export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
});
|
||||
|
||||
app.put("/api/history/folders/:id/collapsed", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const meta = await loadMeta();
|
||||
const folder = meta.folders.find(f => f.id === req.params.id);
|
||||
const meta = await loadMeta(scope);
|
||||
const folder = meta.folders.find((f) => f.id === req.params.id);
|
||||
if (!folder) return res.status(404).json({ error: "Folder not found" });
|
||||
folder.collapsed = !!req.body.collapsed;
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json({ ok: true, collapsed: folder.collapsed });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a folder — items move back to uncategorized
|
||||
// Delete a folder — items move back to uncategorized.
|
||||
app.delete("/api/history/folders/:id", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const meta = await loadMeta();
|
||||
const idx = meta.folders.findIndex(f => f.id === req.params.id);
|
||||
const meta = await loadMeta(scope);
|
||||
const idx = meta.folders.findIndex((f) => f.id === req.params.id);
|
||||
if (idx === -1) return res.status(404).json({ error: "Folder not found" });
|
||||
const [folder] = meta.folders.splice(idx, 1);
|
||||
meta.uncategorized = [...folder.items, ...meta.uncategorized];
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Move a session to a folder (or uncategorized if folderId is null)
|
||||
// Move a session to a folder (or uncategorized if folderId is null).
|
||||
app.put("/api/history/move", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const { sessionId, folderId, index } = req.body;
|
||||
const meta = await loadMeta();
|
||||
const meta = await loadMeta(scope);
|
||||
|
||||
// Remove from current location
|
||||
meta.uncategorized = meta.uncategorized.filter(id => id !== sessionId);
|
||||
meta.uncategorized = meta.uncategorized.filter((id) => id !== sessionId);
|
||||
for (const folder of meta.folders) {
|
||||
folder.items = folder.items.filter(id => id !== sessionId);
|
||||
folder.items = folder.items.filter((id) => id !== sessionId);
|
||||
}
|
||||
|
||||
// Add to new location
|
||||
if (folderId) {
|
||||
const folder = meta.folders.find(f => f.id === folderId);
|
||||
const folder = meta.folders.find((f) => f.id === folderId);
|
||||
if (folder) {
|
||||
const i = typeof index === "number" ? index : folder.items.length;
|
||||
folder.items.splice(i, 0, sessionId);
|
||||
@@ -266,10 +642,20 @@ export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
meta.uncategorized.splice(i, 0, sessionId);
|
||||
}
|
||||
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Allow the same character set as scope components for session ids.
|
||||
// Belt-and-suspenders against ../../ in :id; ids generated by
|
||||
// saveToHistory always match.
|
||||
function safeFilename(s) {
|
||||
if (typeof s !== "string" || !/^[A-Za-z0-9_-]+$/.test(s)) {
|
||||
throw new Error("invalid_session_id");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
+1929
-349
File diff suppressed because it is too large
Load Diff
+84
-35
@@ -4,45 +4,88 @@
|
||||
// directly because library import predates the subscriptions module
|
||||
// having a public 'merge' helper (and the merge logic is library-
|
||||
// specific anyway).
|
||||
//
|
||||
// As of 0.2.77 (multi-tenant): all reads/writes are scoped to the
|
||||
// requesting user via scopeForRequest(req). In single mode the scope
|
||||
// is always "owner" — preserved single-user behavior. In multi mode
|
||||
// each tenant exports/imports their own library; the operator's
|
||||
// admin status doesn't grant cross-user export (a separate operator-
|
||||
// only "export everyone's library" endpoint can be added later if
|
||||
// the operator ever needs it).
|
||||
//
|
||||
// Subscriptions remain global (one /data/history/subscriptions.json
|
||||
// per install) for now. Per-user subscriptions are a Phase 1D/2 task.
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import express from "express";
|
||||
import { getHistoryDir, loadMeta, saveMeta } from "./history.js";
|
||||
import {
|
||||
getHistoryDir,
|
||||
getScopeHistoryDir,
|
||||
loadMeta,
|
||||
saveMeta,
|
||||
scopeForRequest,
|
||||
ROOT_SIDECARS,
|
||||
} from "./history.js";
|
||||
|
||||
// ── Routes ──────────────────────────────────────────────────────────────────
|
||||
// Both routes are gated by the Pro 'library' entitlement (the gate runs
|
||||
// upstream in license-middleware.js). They assume initHistory() has
|
||||
// already been called.
|
||||
export function setupLibraryRoutes(app) {
|
||||
function requireScope(req, res) {
|
||||
try {
|
||||
return scopeForRequest(req);
|
||||
} catch {
|
||||
res.status(401).json({ error: "auth_required" });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk export everything: meta, sessions, subscriptions.
|
||||
app.get("/api/library/export", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const historyDir = getHistoryDir();
|
||||
const meta = await loadMeta();
|
||||
const files = await fs.readdir(historyDir);
|
||||
const scopeDir = getScopeHistoryDir(scope);
|
||||
const meta = await loadMeta(scope);
|
||||
let files = [];
|
||||
try {
|
||||
files = await fs.readdir(scopeDir);
|
||||
} catch {
|
||||
files = [];
|
||||
}
|
||||
const sessions = {};
|
||||
for (const file of files) {
|
||||
if (
|
||||
!file.endsWith(".json") ||
|
||||
file === "_meta.json" ||
|
||||
file === "subscriptions.json" ||
|
||||
file === "auto-queue.json" ||
|
||||
file === "skip-list.json" ||
|
||||
file === "seen-list.json"
|
||||
) continue;
|
||||
// Skip non-sessions: meta + the subscription sidecar files that now
|
||||
// live inside the scope dir (else they'd export as phantom sessions).
|
||||
if (!file.endsWith(".json") || ROOT_SIDECARS.has(file)) continue;
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(historyDir, file), "utf-8");
|
||||
const raw = await fs.readFile(path.join(scopeDir, file), "utf-8");
|
||||
const id = file.replace(".json", "");
|
||||
sessions[id] = JSON.parse(raw);
|
||||
} catch {}
|
||||
}
|
||||
// Subscriptions live at the install-wide history root and are
|
||||
// operator-owned (single global store). Only the operator exports
|
||||
// them — in single mode (the operator owns the box) or, in multi
|
||||
// mode, the admin. A non-admin tenant's export must NOT leak the
|
||||
// operator's subscription list. Future per-user subscriptions move
|
||||
// this into the scope dir.
|
||||
let subscriptions = [];
|
||||
const ownsSubscriptions =
|
||||
req.recapMode !== "multi" || !!(req.user && req.user.is_admin);
|
||||
if (ownsSubscriptions) {
|
||||
try {
|
||||
subscriptions = JSON.parse(
|
||||
await fs.readFile(path.join(historyDir, "subscriptions.json"), "utf-8")
|
||||
subscriptions =
|
||||
JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(getHistoryDir(), "subscriptions.json"),
|
||||
"utf-8",
|
||||
),
|
||||
).subscriptions || [];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const exportData = {
|
||||
version: 1,
|
||||
@@ -54,7 +97,7 @@ export function setupLibraryRoutes(app) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
'attachment; filename="recap-library.json"'
|
||||
'attachment; filename="recap-library.json"',
|
||||
);
|
||||
res.json(exportData);
|
||||
} catch (err) {
|
||||
@@ -68,6 +111,8 @@ export function setupLibraryRoutes(app) {
|
||||
"/api/library/import",
|
||||
express.json({ limit: "200mb" }),
|
||||
async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const data = req.body;
|
||||
if (!data || !data.sessions) {
|
||||
@@ -76,13 +121,15 @@ export function setupLibraryRoutes(app) {
|
||||
.json({ error: "Invalid library file — missing sessions data" });
|
||||
}
|
||||
|
||||
const historyDir = getHistoryDir();
|
||||
const scopeDir = getScopeHistoryDir(scope);
|
||||
await fs.mkdir(scopeDir, { recursive: true }).catch(() => {});
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
|
||||
// Sessions — skip if already present.
|
||||
for (const [id, session] of Object.entries(data.sessions)) {
|
||||
const filePath = path.join(historyDir, `${id}.json`);
|
||||
const filePath = path.join(scopeDir, `${id}.json`);
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
skipped++;
|
||||
@@ -94,20 +141,20 @@ export function setupLibraryRoutes(app) {
|
||||
|
||||
// Meta — merge folders + add new uncategorized at the top.
|
||||
if (data.meta) {
|
||||
const existingMeta = await loadMeta();
|
||||
const existingMeta = await loadMeta(scope);
|
||||
const allExistingIds = new Set([
|
||||
...existingMeta.uncategorized,
|
||||
...existingMeta.folders.flatMap(f => f.items),
|
||||
...existingMeta.folders.flatMap((f) => f.items),
|
||||
]);
|
||||
|
||||
if (data.meta.folders) {
|
||||
for (const folder of data.meta.folders) {
|
||||
const existingFolder = existingMeta.folders.find(
|
||||
f => f.id === folder.id
|
||||
(f) => f.id === folder.id,
|
||||
);
|
||||
if (!existingFolder) {
|
||||
existingMeta.folders.push(folder);
|
||||
folder.items.forEach(id => allExistingIds.add(id));
|
||||
folder.items.forEach((id) => allExistingIds.add(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,29 +167,31 @@ export function setupLibraryRoutes(app) {
|
||||
}
|
||||
}
|
||||
|
||||
await saveMeta(existingMeta);
|
||||
await saveMeta(scope, existingMeta);
|
||||
}
|
||||
|
||||
// Subscriptions — merge, dedupe by URL.
|
||||
if (data.subscriptions && data.subscriptions.length > 0) {
|
||||
// Subscriptions — install-wide + operator-owned. Only the operator
|
||||
// (single mode, or multi-mode admin) may import them; otherwise a
|
||||
// tenant's import would inject into the operator's global list.
|
||||
const ownsSubscriptions =
|
||||
req.recapMode !== "multi" || !!(req.user && req.user.is_admin);
|
||||
if (ownsSubscriptions && data.subscriptions && data.subscriptions.length > 0) {
|
||||
const subsPath = path.join(getHistoryDir(), "subscriptions.json");
|
||||
let existingSubs = [];
|
||||
try {
|
||||
existingSubs = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(historyDir, "subscriptions.json"),
|
||||
"utf-8"
|
||||
)
|
||||
).subscriptions || [];
|
||||
existingSubs =
|
||||
JSON.parse(await fs.readFile(subsPath, "utf-8"))
|
||||
.subscriptions || [];
|
||||
} catch {}
|
||||
const existingUrls = new Set(existingSubs.map(s => s.url));
|
||||
const existingUrls = new Set(existingSubs.map((s) => s.url));
|
||||
for (const sub of data.subscriptions) {
|
||||
if (!existingUrls.has(sub.url)) {
|
||||
existingSubs.push(sub);
|
||||
}
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(historyDir, "subscriptions.json"),
|
||||
JSON.stringify({ subscriptions: existingSubs })
|
||||
subsPath,
|
||||
JSON.stringify({ subscriptions: existingSubs }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,6 +204,6 @@ export function setupLibraryRoutes(app) {
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -247,6 +247,30 @@ const LICENSE_OPEN_PREFIXES = [
|
||||
"/api/library",
|
||||
"/api/providers",
|
||||
"/api/process",
|
||||
// Audio-first ("walking mode") TTS. The /api/tts routes self-gate
|
||||
// access (Max entitlement in multi mode; operator-only otherwise), so
|
||||
// the blanket license middleware must let them through to that gate
|
||||
// rather than 402-ing single-mode operators or Max users here.
|
||||
"/api/tts",
|
||||
// In-app purchase flow: GET /api/license/policies, POST
|
||||
// /api/license/purchase, GET /api/license/poll/<invoiceId>. Buyers
|
||||
// are unlicensed by definition — they must reach these before any
|
||||
// license exists.
|
||||
"/api/license/policies",
|
||||
"/api/license/purchase",
|
||||
"/api/license/poll",
|
||||
// Relay credit top-up purchases: GET /api/credits/packages, POST
|
||||
// /api/credits/buy, GET /api/credits/invoice/<id>. Buying credits
|
||||
// doesn't require a license — Core (free) users should be able to
|
||||
// top up just as easily as Pro/Max. The relay itself enforces
|
||||
// billing via BTCPay; we just proxy.
|
||||
"/api/credits",
|
||||
// Self-serve subscription purchase: POST /api/billing/buy, GET
|
||||
// /api/billing/status. A Core (free) user buying their way UP to
|
||||
// Pro/Max is unlicensed by definition, so the activation gate must
|
||||
// let them reach the buy + poll routes. The routes self-gate to a
|
||||
// real signed-in user (req.user.id).
|
||||
"/api/billing",
|
||||
];
|
||||
|
||||
// ── Pro-tier feature gates ──────────────────────────────────────────────────
|
||||
@@ -264,7 +288,7 @@ const PRO_FEATURE_GATES = [
|
||||
entitlement: "subscriptions",
|
||||
feature: "subscriptions",
|
||||
message:
|
||||
"Channel subscriptions and auto-queue require a paid license. Upgrade to unlock.",
|
||||
"Channel subscriptions and auto-queue require a Pro or Max plan. Upgrade to unlock.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -286,7 +310,7 @@ export function setupLicenseMiddleware(app) {
|
||||
message:
|
||||
LIC.state === "licensed"
|
||||
? "Your license is missing the 'pro' or 'max' entitlement. Contact the seller."
|
||||
: "This feature requires a Recap license. Upgrade to unlock.",
|
||||
: "This feature requires a Recaps license. Upgrade to unlock.",
|
||||
state: LIC.state,
|
||||
reason: LIC.reason,
|
||||
activate_url: "/#activate",
|
||||
@@ -299,6 +323,25 @@ export function setupLicenseMiddleware(app) {
|
||||
app.use((req, res, next) => {
|
||||
for (const gate of PRO_FEATURE_GATES) {
|
||||
if (gate.prefixes.some((p) => req.path.startsWith(p))) {
|
||||
// Multi mode (cloud): per-tenant — the user's relay-owned tier
|
||||
// decides. Pro/Max (or the admin/operator) get in; free tenants get
|
||||
// a clear 402. This is the per-tenant subscriptions gate.
|
||||
if (req.recapMode === "multi") {
|
||||
const tier = req.user?.tier;
|
||||
if (
|
||||
(req.user && req.user.is_admin) ||
|
||||
tier === "pro" ||
|
||||
tier === "max"
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
return res.status(402).json({
|
||||
error: "feature_not_in_tier",
|
||||
feature: gate.feature,
|
||||
message: gate.message,
|
||||
});
|
||||
}
|
||||
// Single mode: the operator's own license carries the entitlement.
|
||||
if (LIC.entitlements.has(gate.entitlement)) return next();
|
||||
return res.status(402).json({
|
||||
error: "feature_not_in_tier",
|
||||
@@ -317,7 +360,44 @@ export function setupLicenseMiddleware(app) {
|
||||
// Open by virtue of being in LICENSE_OPEN_PATHS — the gate lets them
|
||||
// through unauthenticated.
|
||||
export function setupLicenseRoutes(app) {
|
||||
app.get("/api/license-status", (_req, res) => {
|
||||
app.get("/api/license-status", (req, res) => {
|
||||
// ── Multi-mode: return per-user view ────────────────────────────────
|
||||
// The OPERATOR's license at /data/license.txt is "the install" — the
|
||||
// pool that pays for free + trial users. Each signed-in cloud user
|
||||
// has their own state:
|
||||
// - paid user (users.tier = pro|max) → synthesize a license view
|
||||
// from their relay-owned tier (core-decoupling: no Keysat license)
|
||||
// - free tenant (signed in, Core tier) → unlicensed view (they're
|
||||
// not Pro; their balance comes from tenant_credits via /relay/status)
|
||||
// - anonymous/trial → unlicensed view (the badge should show trial
|
||||
// credits, NOT the operator's PRO tier)
|
||||
// - admin (is_admin = 1) → the operator's LIC (they ARE the
|
||||
// operator; same UX as single mode)
|
||||
if (req.recapMode === "multi") {
|
||||
if (req.user && req.user.is_admin) {
|
||||
// Operator viewing their own server: full operator license view.
|
||||
return res.json(license.publicView(LIC));
|
||||
}
|
||||
// Paid cloud user — the tier is the relay-owned subscription tier,
|
||||
// cached on the Recaps account (req.user.tier) and kept in sync by
|
||||
// the operator grant flow. Synthesize a license view from it so the
|
||||
// badge + per-user gates match a license-bearing user. Core-
|
||||
// decoupling: this is the SOLE source of paid status in multi mode —
|
||||
// a leftover per-user keysat_license is deliberately NOT consulted
|
||||
// (licenses are moot in the cloud path), so the badge always agrees
|
||||
// with the relay-owned tier shown in the operator's Tenants panel.
|
||||
const tier = req.user?.tier;
|
||||
if (req.user && (tier === "pro" || tier === "max")) {
|
||||
return res.json(license.viewForTier(tier));
|
||||
}
|
||||
// Free tenant (tier core), trial, or fully anonymous — return an
|
||||
// unlicensed view. Frontend uses this to hide the PRO badge / "manage
|
||||
// license" affordances. Balance display comes from /api/relay/status
|
||||
// (which is also multi-mode-aware).
|
||||
return res.json(license.publicView(license.parseLicenseKey("")));
|
||||
}
|
||||
|
||||
// ── Single-mode (the existing path) ─────────────────────────────────
|
||||
// Opportunistic refresh: if the cached state is more than
|
||||
// OPPORTUNISTIC_REFRESH_THRESHOLD_MS old, fire a validateOnline in
|
||||
// the background. Doesn't block the response — the next status hit
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
// In-app buy flow. Proxies the Keysat public API (policies + purchase
|
||||
// + poll) through Recap so the frontend renders tier cards in Recap's
|
||||
// own visual style instead of being redirected to Keysat's hosted
|
||||
// `/buy/<slug>` page.
|
||||
//
|
||||
// API shape implemented here matches the Keysat spec exactly:
|
||||
// GET /api/license/policies → listPublicPolicies(PRODUCT_SLUG)
|
||||
// POST /api/license/purchase → startPurchase(PRODUCT_SLUG, opts)
|
||||
// GET /api/license/poll/:invoiceId → pollPurchase(invoiceId); on a
|
||||
// settled invoice this also
|
||||
// ACTIVATES the issued license
|
||||
// on this Recap install so the
|
||||
// next license-status refresh
|
||||
// sees the new entitlements.
|
||||
//
|
||||
// All three routes are open to unlicensed users — buyers need to reach
|
||||
// them before they have a license, by definition.
|
||||
|
||||
import { Client } from "@keysat/licensing-client";
|
||||
import * as license from "./license.js";
|
||||
|
||||
const KEYSAT_BASE_URL = license.KEYSAT_BASE_URL;
|
||||
const PRODUCT_SLUG = license.PRODUCT_SLUG;
|
||||
|
||||
// Multi-mode toggle. In single mode (the default), an activated license
|
||||
// is written to /data/license.txt — operator-install-wide. In multi
|
||||
// mode, the buyer is a logged-in user and the license attaches to
|
||||
// THEIR users row instead. Set at module load; the file is re-imported
|
||||
// by tests so module-level state isn't an issue.
|
||||
const RECAP_MODE = process.env.RECAP_MODE === "multi" ? "multi" : "single";
|
||||
|
||||
// Lazy-init the SDK client so a missing import doesn't crash boot.
|
||||
let _client = null;
|
||||
function getClient() {
|
||||
if (!_client) _client = new Client(KEYSAT_BASE_URL);
|
||||
return _client;
|
||||
}
|
||||
|
||||
// Tiny in-memory cache for the policies response. Keysat's admin can
|
||||
// change tiers any time so don't cache long — 30s strikes the balance
|
||||
// between "operator edit takes effect quickly" and "don't hammer the
|
||||
// licensing server on every browser-side modal mount". Pair this
|
||||
// with using ?refresh=1 on the client if you need to invalidate
|
||||
// faster than the TTL.
|
||||
const POLICIES_TTL_MS = 30 * 1000;
|
||||
let _policiesCache = { at: 0, body: null };
|
||||
|
||||
export function setupLicensePurchaseRoutes(app, { onLicenseActivated } = {}) {
|
||||
// ── List tiers ────────────────────────────────────────────────────────────
|
||||
// We deliberately bypass the SDK's listPublicPolicies() here because
|
||||
// it strips fields we need to render the buy page: marketing_bullets,
|
||||
// marketing_bullets_position, hidden_entitlements, and
|
||||
// featured_discount. Hitting the public HTTP endpoint directly gives
|
||||
// us the full snake_case JSON with everything the Keysat hosted /buy
|
||||
// page renders, so we can match its data parity in Recap's own
|
||||
// visual style.
|
||||
app.get("/api/license/policies", async (_req, res) => {
|
||||
if (_policiesCache.body && Date.now() - _policiesCache.at < POLICIES_TTL_MS) {
|
||||
return res.json({
|
||||
keysat_base_url: KEYSAT_BASE_URL,
|
||||
product_slug: PRODUCT_SLUG,
|
||||
..._policiesCache.body,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const url = `${KEYSAT_BASE_URL.replace(/\/$/, "")}/v1/products/${encodeURIComponent(PRODUCT_SLUG)}/policies`;
|
||||
const r = await fetch(url, {
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.text().catch(() => "");
|
||||
throw new Error(`HTTP ${r.status}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
const data = await r.json();
|
||||
_policiesCache = { at: Date.now(), body: data };
|
||||
res.json({
|
||||
keysat_base_url: KEYSAT_BASE_URL,
|
||||
product_slug: PRODUCT_SLUG,
|
||||
...data,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[license/policies] failed: ${err?.message || err}`);
|
||||
res.status(502).json({
|
||||
error: "policies_fetch_failed",
|
||||
message: (err?.message || String(err)).slice(0, 300),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Start purchase ────────────────────────────────────────────────────────
|
||||
// Body: { policySlug: string, buyerEmail?: string, code?: string,
|
||||
// redirectUrl?: string, buyerNote?: string }
|
||||
// Returns the FULL raw Keysat /v1/purchase response, including
|
||||
// discount fields: { invoice_id, checkout_url, final_price_sats,
|
||||
// discount_applied_sats, amount_sats, btcpay_invoice_id, poll_url }.
|
||||
// We bypass the SDK (which strips final_price_sats /
|
||||
// discount_applied_sats from PurchaseSession) so the UI can show
|
||||
// "you saved N sats" before sending the buyer to BTCPay.
|
||||
//
|
||||
// Multi-mode anon-signup flow: if there's no signed-in user AND a
|
||||
// buyer_email is supplied, we treat this as a "create-account-via-
|
||||
// purchase" flow. We record a pending_signups row keyed by the
|
||||
// invoice id so the poll-settle handler can create the user + send
|
||||
// the license-ready magic-link email once payment lands.
|
||||
app.post("/api/license/purchase", async (req, res) => {
|
||||
const {
|
||||
policySlug,
|
||||
buyerEmail,
|
||||
code,
|
||||
redirectUrl,
|
||||
buyerNote,
|
||||
} = req.body || {};
|
||||
if (!policySlug || typeof policySlug !== "string") {
|
||||
return res.status(400).json({
|
||||
error: "missing_policy_slug",
|
||||
message:
|
||||
"policySlug is required — get it from /api/license/policies, never hardcode.",
|
||||
});
|
||||
}
|
||||
// Anon-signup gate: in multi-mode, if no signed-in user, an email
|
||||
// is REQUIRED — that's how the settle handler knows who to send
|
||||
// the "your account is ready" link to. Signed-in users in multi
|
||||
// mode have their email implicit via req.user.email (still passed
|
||||
// via buyerEmail prefill from the frontend), so this only blocks
|
||||
// the truly-anon-no-email edge case.
|
||||
const isAnonSignup =
|
||||
RECAP_MODE === "multi" && !req.user && !!buyerEmail;
|
||||
if (RECAP_MODE === "multi" && !req.user && !buyerEmail) {
|
||||
return res.status(400).json({
|
||||
error: "email_required",
|
||||
message:
|
||||
"Enter your email — we'll send a sign-in link once your payment confirms.",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const url = `${KEYSAT_BASE_URL.replace(/\/$/, "")}/v1/purchase`;
|
||||
const r = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
// Keysat's /v1/purchase expects `product` (NOT `product_slug`,
|
||||
// despite what its own developer-facing instructions show).
|
||||
// The SDK confirms this — see vendor/keysat-licensing-client
|
||||
// dist/index.js, startPurchase POST body.
|
||||
product: PRODUCT_SLUG,
|
||||
policy_slug: policySlug,
|
||||
buyer_email: buyerEmail || undefined,
|
||||
code: code || undefined,
|
||||
redirect_url: redirectUrl || undefined,
|
||||
buyer_note: buyerNote || undefined,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const text = await r.text();
|
||||
let body = null;
|
||||
try { body = text ? JSON.parse(text) : null; } catch {}
|
||||
if (!r.ok) {
|
||||
return res.status(r.status === 400 ? 400 : 502).json({
|
||||
error:
|
||||
(body && (body.error || body.code)) || "purchase_failed",
|
||||
message:
|
||||
(body && (body.message || body.detail)) ||
|
||||
text?.slice(0, 300) ||
|
||||
`HTTP ${r.status}`,
|
||||
});
|
||||
}
|
||||
// Stash pending_signups row BEFORE responding so a browser
|
||||
// crash between buy + settle doesn't lose the buyer's email.
|
||||
// Keysat's response shape: invoice_id at the root.
|
||||
const invoiceId = body?.invoice_id || null;
|
||||
if (isAnonSignup && invoiceId) {
|
||||
try {
|
||||
const { getDb } = await import("./db.js");
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO pending_signups
|
||||
(invoice_id, email, policy_slug, created_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
invoiceId,
|
||||
buyerEmail.trim().toLowerCase(),
|
||||
policySlug,
|
||||
Date.now(),
|
||||
);
|
||||
console.log(
|
||||
`[license/purchase] tracked pending signup ${invoiceId} → ${buyerEmail} (${policySlug})`,
|
||||
);
|
||||
} catch (err) {
|
||||
// Non-fatal — the BTCPay invoice still goes through. If we
|
||||
// can't apply on settle, the operator can manually look up
|
||||
// the Keysat invoice + activate the license + email the
|
||||
// buyer.
|
||||
console.error(
|
||||
`[license/purchase] failed to record pending signup ${invoiceId}: ${err?.message || err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Pass the raw response straight through — the frontend reads
|
||||
// final_price_sats + discount_applied_sats off it to render the
|
||||
// discount preview before opening the checkout.
|
||||
res.json(body || {});
|
||||
} catch (err) {
|
||||
console.error(`[license/purchase] failed: ${err?.message || err}`);
|
||||
res.status(502).json({
|
||||
error: "purchase_failed",
|
||||
message: (err?.message || String(err)).slice(0, 300),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Poll for license issuance ─────────────────────────────────────────────
|
||||
// While the BTCPay invoice is pending, status="pending" and no
|
||||
// licenseKey. Once paid + signed, status="settled" and the response
|
||||
// carries the LIC1-... key. On settle, we ACTIVATE the key on this
|
||||
// install (writes to /data/license.txt, refreshes module-scoped LIC
|
||||
// state) so the next license-status fetch reflects the new
|
||||
// entitlements. The frontend just calls /api/license-status after
|
||||
// this returns settled.
|
||||
app.get("/api/license/poll/:invoiceId", async (req, res) => {
|
||||
const invoiceId = (req.params.invoiceId || "").trim();
|
||||
if (!invoiceId) {
|
||||
return res.status(400).json({ error: "missing_invoice_id" });
|
||||
}
|
||||
try {
|
||||
const poll = await getClient().pollPurchase(invoiceId);
|
||||
if (
|
||||
poll.status === "settled" &&
|
||||
typeof poll.licenseKey === "string" &&
|
||||
poll.licenseKey.startsWith("LIC1-")
|
||||
) {
|
||||
// Settle path forks on mode + buyer identity:
|
||||
// - single mode: write the key to /data/license.txt so the
|
||||
// OPERATOR install is now licensed (existing behavior).
|
||||
// - multi mode + signed-in buyer: attach the key to the
|
||||
// buyer's user row (users.keysat_license). The next
|
||||
// resolveProviderOpts() call for that user picks up the
|
||||
// new license automatically (req.user is re-read
|
||||
// per-request, so the change is immediate after the
|
||||
// next signed-in request hits the server).
|
||||
// - multi mode + anon buyer with pending_signups row:
|
||||
// create-or-match a user by buyer_email, attach the
|
||||
// license, send a magic-link email so they can sign in.
|
||||
// Idempotent via pending_signups.applied_at.
|
||||
// - multi mode + anon buyer with NO pending row: fall back
|
||||
// to the single-mode path (write to /data/license.txt).
|
||||
// This is defensive — shouldn't happen in normal flow.
|
||||
try {
|
||||
let routedToUser = false;
|
||||
if (RECAP_MODE === "multi" && req?.user?.id) {
|
||||
const { getDb } = await import("./db.js");
|
||||
getDb()
|
||||
.prepare("UPDATE users SET keysat_license = ? WHERE id = ?")
|
||||
.run(poll.licenseKey, req.user.id);
|
||||
// Mutate the in-memory req.user so subsequent calls in
|
||||
// the same request see the new license. (next request
|
||||
// re-reads from DB so this is belt-and-suspenders.)
|
||||
req.user.keysat_license = poll.licenseKey;
|
||||
routedToUser = true;
|
||||
console.log(
|
||||
`[license/poll] attached license to user ${req.user.id}`,
|
||||
);
|
||||
} else if (RECAP_MODE === "multi") {
|
||||
const applied = await maybeApplyPendingSignup(
|
||||
invoiceId,
|
||||
poll.licenseKey,
|
||||
req,
|
||||
);
|
||||
if (applied) routedToUser = true;
|
||||
}
|
||||
if (!routedToUser) {
|
||||
license.activate(poll.licenseKey);
|
||||
if (typeof onLicenseActivated === "function") {
|
||||
await onLicenseActivated();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[license/poll] activate-on-settle failed: ${e?.message || e}`
|
||||
);
|
||||
// Don't fail the response — the buyer's checkout succeeded,
|
||||
// they shouldn't see a 500. They can hit "I have a key" and
|
||||
// paste the key manually as a fallback (or, in multi mode,
|
||||
// operator can run an admin SQL update).
|
||||
}
|
||||
}
|
||||
res.json(poll);
|
||||
} catch (err) {
|
||||
console.error(`[license/poll] failed: ${err?.message || err}`);
|
||||
res.status(502).json({
|
||||
error: "poll_failed",
|
||||
message: (err?.message || String(err)).slice(0, 300),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// maybeApplyPendingSignup(invoiceId, licenseKey, req) — multi-mode
|
||||
// anon-signup settle path. Looks up the pending_signups row for this
|
||||
// invoice and, if not already applied:
|
||||
// 1. Create or match a user by buyer email (lowercased)
|
||||
// 2. Attach the issued license to users.keysat_license
|
||||
// 3. Send a "your Recap [Pro|Max] account is ready" magic-link email
|
||||
// 4. Mark applied_at = now
|
||||
// Returns true if applied, false if no pending row or already applied.
|
||||
//
|
||||
// Idempotent: a duplicate poll after settle is a no-op because
|
||||
// applied_at is the guard.
|
||||
async function maybeApplyPendingSignup(invoiceId, licenseKey, req) {
|
||||
const { getDb } = await import("./db.js");
|
||||
const db = getDb();
|
||||
const pending = db
|
||||
.prepare(
|
||||
`SELECT invoice_id, email, policy_slug, applied_at
|
||||
FROM pending_signups WHERE invoice_id = ?`,
|
||||
)
|
||||
.get(invoiceId);
|
||||
if (!pending) return false;
|
||||
if (pending.applied_at) return true; // already applied
|
||||
|
||||
const email = pending.email;
|
||||
const policySlug = pending.policy_slug;
|
||||
|
||||
// Parse the license to derive the tier label (for the email copy).
|
||||
// Falls back to "Pro" if parsing fails — same default as the UI.
|
||||
let tierLabel = "Pro";
|
||||
try {
|
||||
const { parseLicenseKey } = await import("./license.js");
|
||||
const parsed = parseLicenseKey(licenseKey);
|
||||
if (parsed && parsed.entitlements) {
|
||||
if (parsed.entitlements.has("max")) tierLabel = "Max";
|
||||
else if (parsed.entitlements.has("pro")) tierLabel = "Pro";
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Create-or-match user. If a user with this email already exists,
|
||||
// attach the license to their account (case 'a' from the design
|
||||
// doc: "friendliest — handles the 'I forgot I had an account' case").
|
||||
// Otherwise insert a new stub user.
|
||||
let user = db
|
||||
.prepare("SELECT id, email, keysat_license FROM users WHERE email = ?")
|
||||
.get(email);
|
||||
|
||||
const now = Date.now();
|
||||
const tx = db.transaction(() => {
|
||||
let userId;
|
||||
if (user) {
|
||||
userId = user.id;
|
||||
db.prepare(
|
||||
"UPDATE users SET keysat_license = ?, last_signin_at = ? WHERE id = ?",
|
||||
).run(licenseKey, now, userId);
|
||||
} else {
|
||||
userId = randomUuid();
|
||||
const syntheticInstallId = randomUuid();
|
||||
db.prepare(
|
||||
`INSERT INTO users
|
||||
(id, email, created_at, last_signin_at, synthetic_install_id,
|
||||
keysat_license, is_admin)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0)`,
|
||||
).run(userId, email, now, now, syntheticInstallId, licenseKey);
|
||||
console.log(
|
||||
`[license/poll] anon-signup created user ${userId} <${email}> with ${tierLabel} license`,
|
||||
);
|
||||
}
|
||||
db.prepare(
|
||||
"UPDATE pending_signups SET applied_at = ? WHERE invoice_id = ?",
|
||||
).run(now, invoiceId);
|
||||
return userId;
|
||||
});
|
||||
const userId = tx();
|
||||
|
||||
// Send the "your account is ready" magic-link email. Best-effort —
|
||||
// if SMTP fails, the user can hit /auth.html and request a regular
|
||||
// sign-in link (their account now exists with the license attached).
|
||||
try {
|
||||
const { sendSignInLink } = await import("./auth-routes.js");
|
||||
const { renderLicenseReadyEmail } = await import(
|
||||
"./license-ready-email.js"
|
||||
);
|
||||
// Build the email body using the verifyUrl that sendSignInLink
|
||||
// will produce. We pass emailBody to override the default sign-in
|
||||
// copy with the celebratory purchase-confirmation framing.
|
||||
// sendSignInLink generates verifyUrl internally and the body uses
|
||||
// a placeholder we string-substitute below — but actually
|
||||
// sendSignInLink already accepts emailBody. We need to construct
|
||||
// it with the verifyUrl already inserted. So we'll pre-render
|
||||
// emailBody with verifyUrl set to a token-placeholder string,
|
||||
// BUT sendSignInLink doesn't expose verifyUrl... Use a small
|
||||
// bridge: render emailBody after sendSignInLink runs by passing
|
||||
// a callback. Simplest: just send the link via auth-routes'
|
||||
// existing helper which has the right copy via emailBody override.
|
||||
//
|
||||
// Actually sendSignInLink computes verifyUrl after the token is
|
||||
// generated. To inject custom copy, the helper needs verifyUrl
|
||||
// in scope. We pass emailBody as a FUNCTION that receives the
|
||||
// verifyUrl and returns the {subject,text,html} tuple — but the
|
||||
// current signature accepts an object. Refactor in v0.2.94 if
|
||||
// needed. For now the helper accepts either object or function.
|
||||
await sendSignInLink({
|
||||
email,
|
||||
intent: "license_purchase",
|
||||
emailBody: (verifyUrl) =>
|
||||
renderLicenseReadyEmail({
|
||||
verifyUrl,
|
||||
tierLabel,
|
||||
brandName: "Recaps",
|
||||
expiresInMinutes: 15,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[license/poll] license-ready email send failed for ${email}: ${err?.message || err}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Local UUID helper — same shape we use in auth-routes for new users.
|
||||
// Avoids a hard import dep just for this one call.
|
||||
function randomUuid() {
|
||||
// Same crypto.randomBytes(16).toString("hex") pattern used elsewhere.
|
||||
// eslint-disable-next-line global-require
|
||||
const { randomBytes } = require("crypto");
|
||||
return randomBytes(16).toString("hex");
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// "Your Recap [Pro|Max] account is ready" email — sent after an
|
||||
// anon buyer's BTCPay invoice settles. Distinct from the standard
|
||||
// sign-in magic-link email because the framing is celebratory ("your
|
||||
// purchase is confirmed, click here to access") rather than the
|
||||
// transactional "click here to sign in." Both share the underlying
|
||||
// magic-link mechanism — only the copy and subject differ.
|
||||
//
|
||||
// Returns { subject, text, html } in the nodemailer shape.
|
||||
|
||||
export function renderLicenseReadyEmail({
|
||||
verifyUrl,
|
||||
tierLabel = "Pro",
|
||||
brandName = "Recaps",
|
||||
expiresInMinutes = 15,
|
||||
}) {
|
||||
const subject = `Your ${brandName} ${tierLabel} account is ready`;
|
||||
|
||||
const text = [
|
||||
`Thanks for upgrading to ${brandName} ${tierLabel} — your payment is confirmed.`,
|
||||
"",
|
||||
`Click the link below to sign in to your new account. ${tierLabel} is already active and your license is attached.`,
|
||||
"",
|
||||
verifyUrl,
|
||||
"",
|
||||
`This link expires in ${expiresInMinutes} minutes and can only be used once. If it expires, just go back to ${brandName} and request a fresh sign-in link from the same email.`,
|
||||
"",
|
||||
"Welcome aboard.",
|
||||
].join("\n");
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#fafafa;padding:32px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;padding:32px;max-width:90%;">
|
||||
<tr>
|
||||
<td style="font-size:18px;font-weight:600;color:#111;padding-bottom:8px;">
|
||||
Your ${escapeHtml(brandName)} ${escapeHtml(tierLabel)} account is ready
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:14px;line-height:1.55;color:#444;padding-bottom:8px;">
|
||||
Thanks for upgrading — your payment is confirmed and your <strong>${escapeHtml(tierLabel)}</strong> license is attached to your new account.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:14px;line-height:1.55;color:#444;padding-bottom:24px;">
|
||||
Click the button below to sign in. The link expires in ${expiresInMinutes} minutes.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:24px;">
|
||||
<a href="${escapeAttr(verifyUrl)}" style="display:inline-block;background:#3b82f6;color:#fff;text-decoration:none;font-size:15px;font-weight:600;padding:12px 24px;border-radius:6px;">Sign in to ${escapeHtml(brandName)}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;line-height:1.5;color:#888;padding-bottom:8px;">
|
||||
Or copy and paste this URL into your browser:
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:12px;color:#888;word-break:break-all;padding-bottom:24px;">
|
||||
${escapeHtml(verifyUrl)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:12px;line-height:1.5;color:#888;border-top:1px solid #eee;padding-top:16px;">
|
||||
If the link expires before you click it, you can request a fresh sign-in link from ${escapeHtml(brandName)} using this same email. Your ${escapeHtml(tierLabel)} license will already be on the account when you sign in.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return escapeHtml(s);
|
||||
}
|
||||
@@ -230,6 +230,42 @@ export function checkLicense() {
|
||||
return base;
|
||||
}
|
||||
|
||||
// parseLicenseKey(rawKey) — verify an arbitrary LIC1- string without
|
||||
// touching disk or persisted state. Used in multi-tenant mode to build
|
||||
// a publicView for a license stored on a user's row (users.keysat_license)
|
||||
// rather than the operator's /data/license.txt. Returns the same state
|
||||
// shape as checkLicense() so publicView(state) works uniformly.
|
||||
//
|
||||
// Differences from checkLicense:
|
||||
// - Doesn't read /data/license.txt (rawKey is passed in)
|
||||
// - Doesn't layer persisted online state (each user's license has
|
||||
// its own online status; we'd need per-user state files to track
|
||||
// that — out of scope for MVP, accept "offline-verified but
|
||||
// online-unknown" as good enough)
|
||||
export function parseLicenseKey(rawKey) {
|
||||
if (verifierError) {
|
||||
return emptyState({ state: "invalid", reason: `bad embedded key: ${verifierError}` });
|
||||
}
|
||||
const raw = (rawKey || "").trim();
|
||||
if (!raw || !raw.startsWith("LIC1-")) return emptyState();
|
||||
try {
|
||||
const ok = verifier.verify(raw);
|
||||
const payload = ok.payload || {};
|
||||
if (payload.productSlug && payload.productSlug !== PRODUCT_SLUG) {
|
||||
return emptyState({ state: "invalid", reason: "product_mismatch" });
|
||||
}
|
||||
return emptyState({
|
||||
state: "licensed",
|
||||
licenseId: payload.licenseUuid || null,
|
||||
entitlements: new Set(payload.entitlements || []),
|
||||
expiresAt: payload.expiresAt ? new Date(payload.expiresAt * 1000) : null,
|
||||
isTrial: !!(payload.flags & 1),
|
||||
});
|
||||
} catch (e) {
|
||||
return emptyState({ state: "invalid", reason: e?.message || "verify_failed" });
|
||||
}
|
||||
}
|
||||
|
||||
// activate(rawKey) — write a pasted key to disk, then re-check.
|
||||
// Returns the new license state. Throws on bad input format only;
|
||||
// signature failures surface as state: 'invalid' with a reason.
|
||||
@@ -401,6 +437,40 @@ export function publicView(state) {
|
||||
};
|
||||
}
|
||||
|
||||
// viewForTier(tier, opts) — a publicView-shaped object synthesized from a
|
||||
// relay-owned subscription tier ("pro" | "max"), for core-decoupling cloud
|
||||
// users who have NO Keysat license. The entitlement sets mirror the Pro/Max
|
||||
// license keys documented at the top of this file, so the frontend badge AND
|
||||
// the per-user feature gates behave identically to a license-bearing user.
|
||||
// Anything other than "pro"/"max" yields an unlicensed view (no paid
|
||||
// entitlements). `expiresAt` is an optional ISO string (the relay owns the
|
||||
// authoritative expiry; Recaps caches only the tier, so this is usually null).
|
||||
export function viewForTier(tier, { expiresAt = null } = {}) {
|
||||
const t = (tier || "").toLowerCase();
|
||||
let entitlements;
|
||||
if (t === "max") {
|
||||
entitlements = ["max", "subscriptions", "relay_max"];
|
||||
} else if (t === "pro") {
|
||||
entitlements = ["pro", "subscriptions", "relay_pro"];
|
||||
} else {
|
||||
entitlements = [];
|
||||
}
|
||||
return {
|
||||
state: entitlements.length ? "licensed" : "unlicensed",
|
||||
reason: null,
|
||||
licenseId: null,
|
||||
entitlements: entitlements.sort(),
|
||||
expiresAt: expiresAt || null,
|
||||
isTrial: false,
|
||||
productSlug: PRODUCT_SLUG,
|
||||
keysatBaseUrl: KEYSAT_BASE_URL,
|
||||
licensePath: LICENSE_PATH,
|
||||
lastValidatedAt: null,
|
||||
serverStatus: null,
|
||||
graceUntil: null,
|
||||
};
|
||||
}
|
||||
|
||||
// has(state, entitlement) — convenience wrapper for feature gates.
|
||||
export function has(state, entitlement) {
|
||||
return state && state.entitlements && state.entitlements.has(entitlement);
|
||||
|
||||
Generated
+432
-4
@@ -11,8 +11,11 @@
|
||||
"@anthropic-ai/sdk": "^0.95.0",
|
||||
"@google/genai": "^1.41.0",
|
||||
"@keysat/licensing-client": "file:../vendor/keysat-licensing-client",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"cookie": "^1.0.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"nodemailer": "^6.9.16",
|
||||
"openai": "^6.37.0"
|
||||
}
|
||||
},
|
||||
@@ -220,6 +223,17 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "11.10.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
@@ -229,6 +243,26 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
@@ -268,6 +302,30 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
@@ -312,6 +370,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -334,12 +398,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
@@ -383,6 +451,30 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -402,6 +494,15 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -440,6 +541,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -485,6 +595,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
@@ -531,6 +650,15 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
@@ -566,6 +694,12 @@
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -614,6 +748,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -688,6 +828,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "10.6.2",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
|
||||
@@ -818,12 +964,38 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -951,12 +1123,45 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -966,6 +1171,18 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.92.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
|
||||
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
@@ -1004,6 +1221,15 @@
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -1037,6 +1263,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.37.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.37.0.tgz",
|
||||
@@ -1086,6 +1321,33 @@
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.6",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz",
|
||||
@@ -1123,6 +1385,16 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
@@ -1162,6 +1434,35 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||
@@ -1197,6 +1498,18 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
@@ -1320,6 +1633,51 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
@@ -1339,6 +1697,52 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -1354,6 +1758,18 @@
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
@@ -1382,6 +1798,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@@ -1409,6 +1831,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
"@anthropic-ai/sdk": "^0.95.0",
|
||||
"@google/genai": "^1.41.0",
|
||||
"@keysat/licensing-client": "file:../vendor/keysat-licensing-client",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"cors": "^2.8.5",
|
||||
"cookie": "^1.0.1",
|
||||
"express": "^4.21.0",
|
||||
"nodemailer": "^6.9.16",
|
||||
"openai": "^6.37.0"
|
||||
}
|
||||
}
|
||||
|
||||
+56
-13
@@ -24,22 +24,27 @@ import { calcCost } from "../gemini-helpers.js";
|
||||
|
||||
// Models exposed to the analysis fallback chain. Order matters — first
|
||||
// is the preferred default, the rest are tried in order if it fails.
|
||||
// The five Gemini models we expose. Verified valid against
|
||||
// ai.google.dev/gemini-api/docs/models — older IDs (gemini-3-pro-preview
|
||||
// shut down 2026-03-09, gemini-2.0-flash deprecated, gemini-3.1-flash*
|
||||
// never existed) are intentionally not in either list.
|
||||
export const GEMINI_ANALYSIS_MODELS = [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-3.1-flash-lite",
|
||||
];
|
||||
|
||||
// Transcription models, in fallback order. Flash is best speed/cost
|
||||
// for audio → text; 2.5 Flash is the stable previous-gen multimodal
|
||||
// model and works well as a fallback when Gemini 3 Flash returns 503
|
||||
// (capacity / overload). The orchestration layer in server/index.js
|
||||
// iterates this list, retrying with the next model when one fails.
|
||||
// Transcription fallback order: Flash first (Flash is Google's
|
||||
// natural audio fit), Pro only as last-resort because Pro on audio
|
||||
// is significantly more expensive than Flash.
|
||||
export const GEMINI_TRANSCRIPTION_MODELS = [
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.0-flash",
|
||||
"gemini-3.1-flash-lite",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3.1-pro-preview",
|
||||
];
|
||||
|
||||
// Empty-response retries: when the SDK returns 200 with no text (which
|
||||
@@ -178,9 +183,15 @@ export function createGeminiProvider({ apiKey, timeoutMs = 900_000 } = {}) {
|
||||
chapters: chaptersHint,
|
||||
});
|
||||
|
||||
// thinkingLevel: "minimal" is only valid for Flash. Pro models
|
||||
// reject it. Match prior behavior precisely.
|
||||
const txConfig = model.includes("flash")
|
||||
// thinkingLevel is a Gemini 3.x param — Gemini 2.5 models use
|
||||
// a different shape (`thinkingBudget`, integer) and 400 on
|
||||
// `thinkingLevel`. Pro models reject thinking config entirely
|
||||
// for the transcribe pipeline. Only send for Gemini 3.x flash
|
||||
// variants where it's a valid latency/cost knob.
|
||||
const isGemini3Flash =
|
||||
model.includes("flash") &&
|
||||
(model.startsWith("gemini-3-") || model.startsWith("gemini-3.") || model.startsWith("gemini-3."));
|
||||
const txConfig = isGemini3Flash
|
||||
? { thinkingConfig: { thinkingLevel: "minimal" } }
|
||||
: {};
|
||||
|
||||
@@ -199,6 +210,20 @@ export function createGeminiProvider({ apiKey, timeoutMs = 900_000 } = {}) {
|
||||
config: {
|
||||
...txConfig,
|
||||
safetySettings: TRANSCRIPTION_SAFETY,
|
||||
// Transcripts of long audio are output-token-bound.
|
||||
// Gemini's default is small (commonly 8192) which is
|
||||
// enough for ~10-15 min of dense speech but truncates
|
||||
// 30-45 min chunks mid-transcript with no warning.
|
||||
// Observed (May 2026): a 45-min chunk transcribed by
|
||||
// gemini-3.1-flash-lite ended at local 31:05, losing
|
||||
// 14 minutes of speech silently; another chunk lost
|
||||
// 43 of 45 minutes after the model output 5 segments
|
||||
// and stopped. Setting this high gives the model room
|
||||
// to emit the full transcript; models that don't
|
||||
// support values this large will clamp internally to
|
||||
// their max. 65,536 is the upper bound for Gemini 3.x
|
||||
// flash variants per Google's docs.
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contents: [
|
||||
{
|
||||
@@ -272,8 +297,13 @@ export function createGeminiProvider({ apiKey, timeoutMs = 900_000 } = {}) {
|
||||
// UX: fail fast on a single model and let the outer fallback
|
||||
// chain in server/index.js walk to the next model (Pro → Pro
|
||||
// older → Flash → Flash 2.5) immediately. Caller can override
|
||||
// with retries: 2 if they want the old behavior.
|
||||
retries = 1,
|
||||
// Bumped 1 → 2 in 0.2.76 alongside the responseMimeType:json
|
||||
// change. Analyze is by far the cheapest pipeline phase
|
||||
// (~few seconds per call), so a third total attempt (1 initial
|
||||
// + 2 retries on caught error) is essentially free in wall time
|
||||
// but materially reduces "lost window" failures on transient
|
||||
// 503/429 blips. Callers can override.
|
||||
retries = 2,
|
||||
signal,
|
||||
}) {
|
||||
const result = await retryGemini(
|
||||
@@ -281,6 +311,19 @@ export function createGeminiProvider({ apiKey, timeoutMs = 900_000 } = {}) {
|
||||
withAbort(
|
||||
aiAnalyze.models.generateContent({
|
||||
model,
|
||||
config: {
|
||||
// JSON mode — Gemini guarantees the response body is
|
||||
// valid JSON when this is set. Eliminates the entire
|
||||
// class of "invalid JSON in window response" failures
|
||||
// that came from the model occasionally wrapping its
|
||||
// sections array in a prose preamble, a ```json```
|
||||
// markdown fence, or truncating the closing brace.
|
||||
// The prompt already asks for JSON; this turns that
|
||||
// into a hard server-enforced constraint on the
|
||||
// model\'s decoder. Mirrors recap-relay 0.2.69\'s
|
||||
// change for the relay-mode analyze path.
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
@@ -356,7 +399,7 @@ Format each line as:
|
||||
Rules:
|
||||
- Transcribe EVERY word spoken, do not skip or summarize anything.
|
||||
- Use [MM:SS] or [H:MM:SS] timestamp format at the start of each line.
|
||||
- Start a new timestamped line every 15-30 seconds or at natural speech pauses.
|
||||
- Start a new timestamped line every 15-30 seconds or at natural speech pauses or speaker changes.
|
||||
- Include filler words (um, uh, you know) for accuracy.
|
||||
- Speaker identification: FIRST consult the metadata above — descriptions and chapter titles usually name the host(s) and guest(s) explicitly, and the channel name is often the host's name. Match those names to the voices in the audio (introductions, "I'm Dax", "this is Will", first-person references) and use them as speaker labels. Format as: [MM:SS] Name: text. Only fall back to "Host"/"Guest" if no names appear in the metadata AND nobody is introduced by name in the audio.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import { createWhisperProvider } from "./whisper.js";
|
||||
import { createRelayProvider } from "./relay.js";
|
||||
import { getInstallId } from "../install-id.js";
|
||||
import { getRawLicenseKey } from "../license.js";
|
||||
import { getRelayBaseURL } from "../relay-default.js";
|
||||
import { getRelayBaseURL, getRelayOperatorKey } from "../relay-default.js";
|
||||
|
||||
export const PROVIDER_NAMES = [
|
||||
"gemini",
|
||||
@@ -94,10 +94,14 @@ export function getProvider(name, opts = {}) {
|
||||
// `config` is the parsed startos-config.json snapshot.
|
||||
// `clientOpts` is { apiKey?, baseURL? } for THIS provider only —
|
||||
// typically a value out of req.body.providerOpts[name].
|
||||
// `req` is the Express request — only used for the relay provider in
|
||||
// multi-tenant mode, where the relay's install_id + license depend
|
||||
// on WHICH user is making the call. Pass it through whenever a
|
||||
// request is in scope. Single-mode + non-relay providers ignore it.
|
||||
//
|
||||
// Resolution priority for each field: client opt → config opt.
|
||||
// Returns { apiKey?, baseURL? } as appropriate for the provider.
|
||||
export function resolveProviderOpts(name, { config = {}, clientOpts = {} } = {}) {
|
||||
export function resolveProviderOpts(name, { config = {}, clientOpts = {}, req = null } = {}) {
|
||||
const fields = PROVIDER_KEY_FIELDS[name];
|
||||
if (!fields) {
|
||||
throw new Error(`Unknown provider: ${name}`);
|
||||
@@ -141,14 +145,66 @@ export function resolveProviderOpts(name, { config = {}, clientOpts = {} } = {})
|
||||
}
|
||||
}
|
||||
// Relay-specific injections: baseURL (hardcoded constant or env
|
||||
// override) + install-id (always) + license key (when present).
|
||||
// None of these come from clientOpts — relay identity + endpoint
|
||||
// must not be spoofable from a request body.
|
||||
// override) + install-id + license key. None of these come from
|
||||
// clientOpts — relay identity + endpoint must not be spoofable from
|
||||
// a request body.
|
||||
//
|
||||
// Identity rules:
|
||||
// - single mode: always the operator install + operator license
|
||||
// - multi mode + signed-in user WITH their own keysat_license:
|
||||
// user's synthetic_install_id + user's license. The relay's
|
||||
// license-keyed credit ledger (Path 3) routes consumption to
|
||||
// the right user-pool.
|
||||
// - multi mode + free / trial / Core user OR signed-in user with
|
||||
// no license: operator's install + license. Their relay calls
|
||||
// are paid out of the operator's credit pool (tenant_credits
|
||||
// gates them locally to control fan-out).
|
||||
if (name === "relay") {
|
||||
opts.baseURL = getRelayBaseURL();
|
||||
opts.installId = getInstallId();
|
||||
const rawKey = getRawLicenseKey();
|
||||
if (rawKey) opts.licenseKey = rawKey;
|
||||
const ident = pickRelayIdentity(req);
|
||||
if (ident.cloud) {
|
||||
// Core-decoupling cloud identity: authenticate the server with the
|
||||
// operator key + name the user; no per-user Keysat license.
|
||||
opts.cloud = true;
|
||||
opts.userId = ident.userId;
|
||||
opts.operatorKey = ident.operatorKey;
|
||||
} else {
|
||||
opts.installId = ident.installId;
|
||||
if (ident.licenseKey) opts.licenseKey = ident.licenseKey;
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
// pickRelayIdentity(req) — single source of truth for "which (install_id,
|
||||
// license) do we present to the relay for THIS request". Centralized so
|
||||
// the rule doesn't drift across the four resolveProviderOpts callsites.
|
||||
function pickRelayIdentity(req) {
|
||||
// Single mode (or no request in scope, e.g. boot-time relay capability
|
||||
// probe): operator identity, period.
|
||||
if (!req || req.recapMode !== "multi") {
|
||||
return { installId: getInstallId(), licenseKey: getRawLicenseKey() || null };
|
||||
}
|
||||
// Multi mode + PAID cloud user (core-decoupling): cloud identity —
|
||||
// authenticate the server with the operator key and name the user by
|
||||
// their Recaps account id. NO Keysat license; the relay owns the
|
||||
// tier, keyed by user-id. `req.user.tier` is the Recaps-side cache of
|
||||
// that relay tier (kept in sync by the operator grant flow). Falls
|
||||
// back to the operator pool when the operator key isn't configured.
|
||||
const tier = req.user?.tier;
|
||||
if (
|
||||
req.user &&
|
||||
req.user.id &&
|
||||
!req.user.is_admin &&
|
||||
(tier === "pro" || tier === "max")
|
||||
) {
|
||||
const operatorKey = getRelayOperatorKey();
|
||||
if (operatorKey) {
|
||||
return { cloud: true, userId: req.user.id, operatorKey };
|
||||
}
|
||||
}
|
||||
// Multi mode + everyone else (admin, anon trial, signed-in free user,
|
||||
// family-share tenant on a self-hosted multi-tenant operator's box):
|
||||
// pay out of the operator's pool.
|
||||
return { installId: getInstallId(), licenseKey: getRawLicenseKey() || null };
|
||||
}
|
||||
|
||||
+820
-13
@@ -32,7 +32,12 @@
|
||||
import { createReadStream } from "fs";
|
||||
import { retryAPI, formatTime } from "../util.js";
|
||||
import { zeroCost } from "./cost.js";
|
||||
import { updateRelayState, recordRelayError } from "../relay-state.js";
|
||||
import {
|
||||
updateRelayState,
|
||||
recordRelayError,
|
||||
computeCreditKey,
|
||||
} from "../relay-state.js";
|
||||
import { getRelayBaseURL, getRelayOperatorKey } from "../relay-default.js";
|
||||
|
||||
// Provider name shown in logs + chunk pagination labels. "relay" rather
|
||||
// than e.g. "keysat-relay" because operators may run their own relay
|
||||
@@ -49,6 +54,12 @@ export function createRelayProvider({
|
||||
baseURL,
|
||||
installId,
|
||||
licenseKey,
|
||||
// Core-decoupling cloud identity: when `cloud` is set, the relay call
|
||||
// authenticates the SERVER with `operatorKey` and names the user via
|
||||
// `userId` (X-Recap-User-Id) instead of carrying a per-user license.
|
||||
cloud = false,
|
||||
userId = null,
|
||||
operatorKey = null,
|
||||
timeoutMs = 900_000,
|
||||
} = {}) {
|
||||
if (!baseURL) {
|
||||
@@ -56,23 +67,44 @@ export function createRelayProvider({
|
||||
"createRelayProvider: baseURL is required (e.g. https://relay.keysat.xyz)"
|
||||
);
|
||||
}
|
||||
if (!installId) {
|
||||
if (cloud) {
|
||||
if (!userId || !operatorKey) {
|
||||
throw new Error(
|
||||
"createRelayProvider: cloud identity requires userId + operatorKey"
|
||||
);
|
||||
}
|
||||
} else if (!installId) {
|
||||
throw new Error(
|
||||
"createRelayProvider: installId is required (boot must initInstallId first)"
|
||||
);
|
||||
}
|
||||
const base = baseURL.replace(/\/$/, "");
|
||||
|
||||
// Per-identity credit-key for the relay-state cache. Computed once
|
||||
// here because (installId, licenseKey) are fixed for this provider
|
||||
// instance — every subsequent updateRelayState/recordRelayError on
|
||||
// this instance routes to the same cache slot. Multi-mode creates a
|
||||
// fresh provider per request (resolveProviderOpts injects per-user
|
||||
// identity), so each user's relay state stays isolated.
|
||||
const creditKey = computeCreditKey({
|
||||
installId,
|
||||
licenseKey,
|
||||
userId: cloud ? userId : null,
|
||||
});
|
||||
|
||||
// Build the auth/identity headers attached to every relay call.
|
||||
// job_id is optional but the orchestration layer should always pass
|
||||
// one — without it the relay can't bundle the transcribe + analyze
|
||||
// pair into a single credit charge.
|
||||
function buildHeaders({ extra = {}, jobId } = {}) {
|
||||
const h = {
|
||||
"X-Recap-Install-Id": installId,
|
||||
...extra,
|
||||
};
|
||||
const h = { ...extra };
|
||||
if (cloud) {
|
||||
h["X-Recap-User-Id"] = userId;
|
||||
h["X-Recap-Operator-Key"] = operatorKey;
|
||||
} else {
|
||||
h["X-Recap-Install-Id"] = installId;
|
||||
if (licenseKey) h["Authorization"] = `Bearer ${licenseKey}`;
|
||||
}
|
||||
if (jobId) h["X-Recap-Job-Id"] = jobId;
|
||||
return h;
|
||||
}
|
||||
@@ -81,6 +113,39 @@ export function createRelayProvider({
|
||||
// response (success or failure) carries the standard envelope so
|
||||
// Recap can keep its balance display accurate even on errors. We
|
||||
// try to parse error bodies to harvest that.
|
||||
// GET wrapper mirroring postRelay's envelope-aware error handling.
|
||||
// Used by the transcribe-url poll loop to fetch job status.
|
||||
async function getRelay({ path, headers, signal }) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${base}${path}`, { method: "GET", headers, signal });
|
||||
} catch (err) {
|
||||
recordRelayError(err?.message || String(err), creditKey);
|
||||
throw err;
|
||||
}
|
||||
const text = await res.text();
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
if (parsed && (typeof parsed.credits_remaining === "number" || parsed.tier)) {
|
||||
updateRelayState(parsed, creditKey);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
parsed?.error ||
|
||||
parsed?.message ||
|
||||
text?.slice(0, 300) ||
|
||||
`HTTP ${res.status}`;
|
||||
const err = new Error(`Relay GET ${path} ${res.status}: ${msg}`);
|
||||
err.status = res.status;
|
||||
err.envelope = parsed;
|
||||
if (!parsed) recordRelayError(msg, creditKey);
|
||||
throw err;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function postRelay({ path, body, headers, signal }) {
|
||||
let res;
|
||||
try {
|
||||
@@ -91,7 +156,7 @@ export function createRelayProvider({
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
recordRelayError(err?.message || String(err));
|
||||
recordRelayError(err?.message || String(err), creditKey);
|
||||
throw err;
|
||||
}
|
||||
const text = await res.text();
|
||||
@@ -100,7 +165,7 @@ export function createRelayProvider({
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
if (parsed && (typeof parsed.credits_remaining === "number" || parsed.tier)) {
|
||||
updateRelayState(parsed);
|
||||
updateRelayState(parsed, creditKey);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
@@ -111,7 +176,7 @@ export function createRelayProvider({
|
||||
const err = new Error(`Relay ${path} ${res.status}: ${msg}`);
|
||||
err.status = res.status;
|
||||
err.envelope = parsed;
|
||||
if (!parsed) recordRelayError(msg);
|
||||
if (!parsed) recordRelayError(msg, creditKey);
|
||||
throw err;
|
||||
}
|
||||
return parsed;
|
||||
@@ -136,6 +201,524 @@ export function createRelayProvider({
|
||||
return [...RELAY_TRANSCRIPTION_MODELS];
|
||||
},
|
||||
|
||||
// POST /relay/transcribe-url — like transcribeAudio but the
|
||||
// relay fetches the audio from the URL itself (yt-dlp for YouTube,
|
||||
// direct HTTP for podcast RSS audio). Saves the buyer's upload
|
||||
// bandwidth, which is often the slowest leg of the pipeline.
|
||||
//
|
||||
// The relay processes this asynchronously: the POST returns
|
||||
// immediately with a job_id, then we poll GET /relay/jobs/{id}
|
||||
// until status flips to "complete" or "failed". Async pattern is
|
||||
// required because no proxy / load balancer in the path can be
|
||||
// trusted to keep a multi-minute HTTP request alive — short poll
|
||||
// requests, by contrast, are bulletproof.
|
||||
async transcribeUrl({
|
||||
mediaUrl,
|
||||
mediaType, // "youtube" | "podcast" (optional; relay sniffs URL shape)
|
||||
mimeType,
|
||||
titleHint,
|
||||
channelHint = "",
|
||||
descriptionHint = "",
|
||||
chaptersHint = [],
|
||||
onProgress = () => {},
|
||||
signal,
|
||||
jobId,
|
||||
}) {
|
||||
onProgress(`Asking relay to fetch + transcribe ${mediaUrl.slice(0, 80)}...`);
|
||||
const start = Date.now();
|
||||
|
||||
// Step 1: kick off the job. retryAPI handles transient transport
|
||||
// errors on this short request.
|
||||
const initEnvelope = await retryAPI(
|
||||
() =>
|
||||
postRelay({
|
||||
path: "/relay/transcribe-url",
|
||||
body: JSON.stringify({
|
||||
media_url: mediaUrl,
|
||||
type: mediaType || undefined,
|
||||
mime_type: mimeType || undefined,
|
||||
title: titleHint || undefined,
|
||||
channel: channelHint || undefined,
|
||||
description: descriptionHint || undefined,
|
||||
chapters:
|
||||
Array.isArray(chaptersHint) && chaptersHint.length > 0
|
||||
? chaptersHint
|
||||
: undefined,
|
||||
}),
|
||||
headers: buildHeaders({
|
||||
extra: { "Content-Type": "application/json" },
|
||||
jobId,
|
||||
}),
|
||||
signal,
|
||||
}),
|
||||
{
|
||||
retries: 2,
|
||||
delayMs: 5000,
|
||||
label: "Relay transcribe-url (kickoff)",
|
||||
log: (msg) => onProgress(msg),
|
||||
}
|
||||
);
|
||||
|
||||
const kickoffResult = initEnvelope.result || {};
|
||||
const backgroundJobId = kickoffResult.job_id;
|
||||
if (!backgroundJobId) {
|
||||
throw new Error(
|
||||
"Relay transcribe-url didn't return a job_id — old relay version? Re-install relay 0.2.14 or newer."
|
||||
);
|
||||
}
|
||||
onProgress(
|
||||
`Relay accepted job ${backgroundJobId.slice(0, 8)}… processing in background`
|
||||
);
|
||||
|
||||
// Step 2: poll GET /relay/jobs/{id} until complete or failed.
|
||||
// Generous max-wait — relay transcribes for long audio can run
|
||||
// several minutes; we want to wait through that, not give up
|
||||
// prematurely. The poll requests themselves are cheap, so the
|
||||
// cost of a long wait is just time-on-the-clock, not bandwidth.
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const MAX_WAIT_MS = 30 * 60 * 1000; // 30 min
|
||||
const deadline = Date.now() + MAX_WAIT_MS;
|
||||
let lastProgress = null;
|
||||
let pollFailuresInARow = 0;
|
||||
const MAX_CONSECUTIVE_POLL_FAILURES = 6; // ~30s of poll outage
|
||||
let envelope = null;
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Relay job polling aborted");
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(
|
||||
`Relay transcribe-url did not complete within ${Math.round(
|
||||
MAX_WAIT_MS / 60_000
|
||||
)} minutes — giving up`
|
||||
);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
try {
|
||||
envelope = await getRelay({
|
||||
path: `/relay/jobs/${encodeURIComponent(backgroundJobId)}`,
|
||||
headers: buildHeaders({ jobId }),
|
||||
signal,
|
||||
});
|
||||
pollFailuresInARow = 0;
|
||||
} catch (err) {
|
||||
// A 404 specifically means the relay no longer knows about
|
||||
// this job. That's not a network blip — the relay almost
|
||||
// certainly restarted (operator update, crash, manual
|
||||
// restart) and lost in-memory job state. Surface
|
||||
// immediately rather than burning 6 retries; the orchestrator's
|
||||
// fallback logic decides whether to retry from scratch.
|
||||
const status = err?.status || 0;
|
||||
const msg = err?.message || String(err);
|
||||
if (status === 404 || /job_not_found/.test(msg)) {
|
||||
throw new Error(
|
||||
`Relay lost the job (probably restarted) — start over to retry`
|
||||
);
|
||||
}
|
||||
// Other failures (network, TLS, timeout) are transient.
|
||||
// Retry up to MAX_CONSECUTIVE_POLL_FAILURES before giving up.
|
||||
pollFailuresInARow += 1;
|
||||
if (pollFailuresInARow >= MAX_CONSECUTIVE_POLL_FAILURES) {
|
||||
throw new Error(
|
||||
`Relay polling lost — ${pollFailuresInARow} consecutive failures: ${msg}`
|
||||
);
|
||||
}
|
||||
onProgress(
|
||||
`Relay poll glitch (${pollFailuresInARow}/${MAX_CONSECUTIVE_POLL_FAILURES}): ${msg.slice(0, 100)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const jobRes = envelope.result || {};
|
||||
if (jobRes.progress && jobRes.progress !== lastProgress) {
|
||||
lastProgress = jobRes.progress;
|
||||
onProgress(`Relay: ${jobRes.progress}`);
|
||||
}
|
||||
if (jobRes.status === "complete") break;
|
||||
if (jobRes.status === "failed") {
|
||||
throw new Error(
|
||||
jobRes.error || "Relay transcribe-url job failed (no detail)"
|
||||
);
|
||||
}
|
||||
// "queued" / "running" → keep polling
|
||||
}
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
const remaining =
|
||||
typeof envelope.credits_remaining === "number"
|
||||
? `, ${envelope.credits_remaining} credits left`
|
||||
: "";
|
||||
onProgress(`Relay transcribe-url complete in ${elapsed}s${remaining}`);
|
||||
|
||||
// The job's result field carries the transcribe backend's
|
||||
// output verbatim — same shape as the (sync) transcribeAudio
|
||||
// result. Walk segments → bracketed text the same way.
|
||||
const innerResult = envelope.result?.result || {};
|
||||
const segments = Array.isArray(innerResult.segments)
|
||||
? innerResult.segments
|
||||
: [];
|
||||
const lines = segments.length
|
||||
? segments.map(
|
||||
(s) => `[${formatTime(s.start || 0)}] ${(s.text || "").trim()}`
|
||||
)
|
||||
: [`[0:00] ${(innerResult.text || "").trim()}`];
|
||||
const text = lines.join("\n");
|
||||
const cost = zeroCost({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
thinkingTokens: 0,
|
||||
});
|
||||
return {
|
||||
text,
|
||||
usage: { inputTokens: 0, outputTokens: 0, thinkingTokens: 0, totalTokens: 0 },
|
||||
cost,
|
||||
finishReason: null,
|
||||
blockReason: "none",
|
||||
raw: envelope,
|
||||
};
|
||||
},
|
||||
|
||||
// POST /relay/summarize-url — combined transcribe+analyze pipeline
|
||||
// that streams per-window section results back over SSE. Used in
|
||||
// "Recap Relay" mode where the user has chosen the operator's
|
||||
// relay for the WHOLE pipeline (not per-step). Replaces the
|
||||
// old transcribeUrl + per-window analyzeText fan-out with a single
|
||||
// server-side pipeline, saving ~12 round-trips per long video and
|
||||
// letting the operator's Settings-tab chunking knobs actually
|
||||
// drive production behavior (instead of just benchmarks).
|
||||
//
|
||||
// Flow:
|
||||
// 1. POST /relay/summarize-url → returns job_id immediately
|
||||
// 2. GET /relay/summarize-url/:jobId/events (SSE) → stream
|
||||
// transcribe_complete + window_complete + done events
|
||||
// 3. onProgress / onWindowComplete callbacks fire as events
|
||||
// arrive (mirrors the recap-app's chunked-analyze.js shape
|
||||
// so the existing UI rendering code keeps working)
|
||||
// 4. Returns the final { transcript, sections } envelope when
|
||||
// "done" arrives. Throws on "error" or stream close before
|
||||
// done.
|
||||
//
|
||||
// Falls back to one-shot poll-based completion if SSE never
|
||||
// connects (e.g. operator's reverse proxy strips text/event-stream
|
||||
// — observed with overly-aggressive content-type filters).
|
||||
async summarizeUrl({
|
||||
mediaUrl,
|
||||
mediaType,
|
||||
mimeType,
|
||||
titleHint,
|
||||
channelHint = "",
|
||||
descriptionHint = "",
|
||||
chaptersHint = [],
|
||||
onProgress = () => {},
|
||||
onWindowComplete = null,
|
||||
// Fires when the relay's SSE stream emits transcribe_complete.
|
||||
// The full transcript text is available BEFORE any analyze
|
||||
// window completes (analyze runs after transcribe finishes),
|
||||
// so subscribing here lets the caller parse the transcript
|
||||
// into entries and have them ready in time for the FIRST
|
||||
// window_complete callback. Used by Recap-app's relay-mode
|
||||
// branch to stream per-window section chunks to the browser
|
||||
// incrementally — without parsed entries the chunks can\'t be
|
||||
// assembled, so this callback is the dependency that unblocks
|
||||
// streaming.
|
||||
onTranscribeComplete = null,
|
||||
signal,
|
||||
jobId,
|
||||
}) {
|
||||
onProgress(`Asking relay to summarize ${mediaUrl.slice(0, 80)}...`);
|
||||
const start = Date.now();
|
||||
|
||||
// Step 1: kick off the job.
|
||||
const initEnvelope = await retryAPI(
|
||||
() =>
|
||||
postRelay({
|
||||
path: "/relay/summarize-url",
|
||||
body: JSON.stringify({
|
||||
media_url: mediaUrl,
|
||||
type: mediaType || undefined,
|
||||
mime_type: mimeType || undefined,
|
||||
title: titleHint || undefined,
|
||||
channel: channelHint || undefined,
|
||||
description: descriptionHint || undefined,
|
||||
chapters:
|
||||
Array.isArray(chaptersHint) && chaptersHint.length > 0
|
||||
? chaptersHint
|
||||
: undefined,
|
||||
}),
|
||||
headers: buildHeaders({
|
||||
extra: { "Content-Type": "application/json" },
|
||||
jobId,
|
||||
}),
|
||||
signal,
|
||||
}),
|
||||
{
|
||||
retries: 2,
|
||||
delayMs: 5000,
|
||||
label: "Relay summarize-url (kickoff)",
|
||||
log: (msg) => onProgress(msg),
|
||||
}
|
||||
);
|
||||
const kickoffResult = initEnvelope.result || {};
|
||||
const backgroundJobId = kickoffResult.job_id;
|
||||
if (!backgroundJobId) {
|
||||
throw new Error(
|
||||
"Relay summarize-url didn't return a job_id — old relay version? Re-install relay 0.2.33 or newer."
|
||||
);
|
||||
}
|
||||
onProgress(
|
||||
`Relay accepted job ${backgroundJobId.slice(0, 8)}… streaming`
|
||||
);
|
||||
|
||||
// Step 2: open SSE stream for live events.
|
||||
// We use fetch + manual SSE parsing rather than EventSource
|
||||
// because (a) Node's global EventSource is recent (24+), (b)
|
||||
// we need custom auth headers which EventSource doesn't support.
|
||||
let sseRes;
|
||||
try {
|
||||
sseRes = await fetch(
|
||||
`${base}/relay/summarize-url/${encodeURIComponent(backgroundJobId)}/events`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: buildHeaders({
|
||||
extra: { Accept: "text/event-stream" },
|
||||
jobId,
|
||||
}),
|
||||
signal,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
recordRelayError(err?.message || String(err), creditKey);
|
||||
throw new Error(
|
||||
`Relay summarize-url SSE connect failed: ${err?.message || err}`
|
||||
);
|
||||
}
|
||||
if (!sseRes.ok || !sseRes.body) {
|
||||
throw new Error(
|
||||
`Relay summarize-url SSE returned ${sseRes.status} ${sseRes.statusText || ""}`.trim()
|
||||
);
|
||||
}
|
||||
// Verify the server actually returned an event stream. Some
|
||||
// reverse proxies silently rewrite the content-type which
|
||||
// breaks SSE without raising an HTTP error.
|
||||
const ct = sseRes.headers.get("content-type") || "";
|
||||
if (!ct.includes("text/event-stream")) {
|
||||
throw new Error(
|
||||
`Relay summarize-url SSE expected text/event-stream, got "${ct}" — check your reverse proxy config`
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: parse SSE frames as they arrive, dispatch to callbacks.
|
||||
// SSE frame syntax: blocks separated by \n\n, each block is a
|
||||
// sequence of "field: value" lines. We collect event/data/id
|
||||
// pairs and fire on each completed frame.
|
||||
const reader = sseRes.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
let finalResult = null;
|
||||
let finalError = null;
|
||||
let transcriptText = null;
|
||||
const sectionsByWindow = new Map(); // windowIdx → owned sections
|
||||
let totalWindows = 0;
|
||||
let windowsDone = 0;
|
||||
|
||||
const handleFrame = (frame) => {
|
||||
if (!frame || !frame.trim()) return;
|
||||
let eventType = "message";
|
||||
let dataStr = "";
|
||||
for (const rawLine of frame.split(/\r?\n/)) {
|
||||
if (!rawLine || rawLine.startsWith(":")) continue;
|
||||
const colon = rawLine.indexOf(":");
|
||||
if (colon < 0) continue;
|
||||
const field = rawLine.slice(0, colon).trim();
|
||||
const value = rawLine.slice(colon + 1).replace(/^ /, "");
|
||||
if (field === "event") eventType = value;
|
||||
else if (field === "data") dataStr += (dataStr ? "\n" : "") + value;
|
||||
}
|
||||
if (!dataStr) return;
|
||||
let data;
|
||||
try { data = JSON.parse(dataStr); }
|
||||
catch { return; }
|
||||
|
||||
if (eventType === "progress") {
|
||||
if (data.message) onProgress(`Relay: ${data.message}`);
|
||||
} else if (eventType === "transcribe_complete") {
|
||||
transcriptText = data.transcript || "";
|
||||
onProgress(
|
||||
`Relay transcribe done — ${data.chunk_count ?? "?"} chunks, ${Math.round((data.audio_seconds || 0) / 60)} min audio`
|
||||
);
|
||||
if (onTranscribeComplete) {
|
||||
try {
|
||||
onTranscribeComplete({
|
||||
transcript: transcriptText,
|
||||
chunk_count: data.chunk_count ?? null,
|
||||
audio_seconds: data.audio_seconds ?? null,
|
||||
model: data.model || null,
|
||||
});
|
||||
} catch (cbErr) {
|
||||
onProgress(`transcribe_complete callback error: ${cbErr?.message || cbErr}`);
|
||||
}
|
||||
}
|
||||
} else if (eventType === "window_complete") {
|
||||
totalWindows = data.totalWindows || totalWindows;
|
||||
sectionsByWindow.set(data.windowIdx, data.ownedSections || []);
|
||||
windowsDone += 1;
|
||||
if (onWindowComplete) {
|
||||
try {
|
||||
onWindowComplete({
|
||||
windowIdx: data.windowIdx,
|
||||
totalWindows: data.totalWindows,
|
||||
ownedSections: data.ownedSections || [],
|
||||
// Pipelined mode (relay v0.2.89+) attaches the
|
||||
// window's own entries here. Sequential mode (older
|
||||
// relays OR Gemini-transcribe path) omits the field,
|
||||
// which signals to the caller to fall back to the
|
||||
// global streamedRelayEntries cache populated by
|
||||
// onTranscribeComplete.
|
||||
windowEntries: Array.isArray(data.windowEntries)
|
||||
? data.windowEntries
|
||||
: null,
|
||||
});
|
||||
} catch (cbErr) {
|
||||
// Surface to caller log but don't kill the stream.
|
||||
onProgress(`window_complete callback error: ${cbErr?.message || cbErr}`);
|
||||
}
|
||||
}
|
||||
onProgress(`Relay analyze: ${windowsDone}/${data.totalWindows} windows complete`);
|
||||
} else if (eventType === "done") {
|
||||
// Relay versions <= 0.2.59 emitted the SSE done event with a
|
||||
// double-nested shape — markComplete put the whole envelope
|
||||
// (`{result: {inner}, credit_charged, tier}`) into the event,
|
||||
// so `data.result` was the envelope and the actual fields
|
||||
// (title, transcript, analyze_model) lived at
|
||||
// `data.result.result.*`. Relay 0.2.60+ unwraps before
|
||||
// emitting, so `data.result.title` is correct directly.
|
||||
// Detect the old shape by checking if `data.result.result`
|
||||
// exists and looks like the inner object (has the keys we
|
||||
// expect to find at the top of `data.result`). Unwrap once
|
||||
// when present. Backwards-compatible — works against any
|
||||
// relay version.
|
||||
const raw = data.result || {};
|
||||
if (
|
||||
raw && typeof raw === "object" &&
|
||||
raw.result && typeof raw.result === "object" &&
|
||||
("transcript" in raw.result ||
|
||||
"analysis" in raw.result ||
|
||||
"title" in raw.result)
|
||||
) {
|
||||
finalResult = raw.result;
|
||||
} else {
|
||||
finalResult = raw;
|
||||
}
|
||||
} else if (eventType === "error") {
|
||||
finalError = new Error(data.error || "relay summarize-url failed");
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
try { reader.cancel(); } catch {}
|
||||
throw new Error("Relay summarize-url aborted");
|
||||
}
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
// Frames are separated by blank lines.
|
||||
let idx;
|
||||
while ((idx = buffer.indexOf("\n\n")) >= 0) {
|
||||
const frame = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
handleFrame(frame);
|
||||
}
|
||||
if (finalResult || finalError) {
|
||||
try { reader.cancel(); } catch {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { reader.releaseLock(); } catch {}
|
||||
}
|
||||
|
||||
if (finalError) throw finalError;
|
||||
if (!finalResult) {
|
||||
throw new Error(
|
||||
"Relay summarize-url SSE closed before 'done' event — connection dropped mid-flight"
|
||||
);
|
||||
}
|
||||
|
||||
// The "done" event carries the final stitched analysis result.
|
||||
// Stitch our own ordered sections list from sectionsByWindow as
|
||||
// a defensive fallback — but trust finalResult.analysis.sections
|
||||
// when present (it's the relay's authoritative stitch).
|
||||
const stitchedAnalysis =
|
||||
(finalResult.analysis && Array.isArray(finalResult.analysis.sections))
|
||||
? finalResult.analysis
|
||||
: {
|
||||
sections: [...sectionsByWindow.keys()]
|
||||
.sort((a, b) => a - b)
|
||||
.flatMap((k) => sectionsByWindow.get(k) || []),
|
||||
};
|
||||
|
||||
// Also refresh the relay state with the final envelope's
|
||||
// credit balance so the picker's "credits remaining" pill
|
||||
// updates without a separate /api/relay/status round-trip.
|
||||
// (initEnvelope already had a current snapshot; final state
|
||||
// applies after the job completed.)
|
||||
if (typeof initEnvelope.credits_remaining === "number") {
|
||||
updateRelayState(initEnvelope, creditKey);
|
||||
}
|
||||
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
onProgress(`Relay summarize-url complete in ${elapsed}s`);
|
||||
|
||||
return {
|
||||
// Transcript in bracketed [MM:SS] form (same shape as
|
||||
// transcribeUrl returns), for downstream code paths that
|
||||
// still parse text into entries.
|
||||
transcript: transcriptText || finalResult.transcript || "",
|
||||
// Final stitched analysis as JSON { sections: [...] } with
|
||||
// GLOBAL startIndex / endIndex into the transcript entries.
|
||||
analysis: stitchedAnalysis,
|
||||
// Resolved media title — either the operator-supplied hint or
|
||||
// the title yt-dlp extracted from YouTube during download.
|
||||
// Older relays (< 0.2.53) don't include this field; the
|
||||
// caller falls back to its own titleSurrogate when it's null.
|
||||
title: finalResult.title || null,
|
||||
// Model attribution + timing diagnostics.
|
||||
transcribe_model: finalResult.transcribe_model || null,
|
||||
analyze_model: finalResult.analyze_model || null,
|
||||
audio_seconds: finalResult.audio_seconds || null,
|
||||
audio_bytes: finalResult.audio_bytes || null,
|
||||
wall_time_ms: finalResult.wall_time_ms || null,
|
||||
chunk_count: finalResult.chunk_count || null,
|
||||
analyze_windows: finalResult.analyze_windows || totalWindows,
|
||||
analyze_windows_failed: finalResult.analyze_windows_failed || 0,
|
||||
// Phase 1D — speaker diarization output from operator
|
||||
// hardware. Both null on relays < 0.2.88 OR when diarization
|
||||
// was off OR when no fingerprints could be collected. When
|
||||
// present:
|
||||
// speakers — map { Speaker_A: { turns, total_speaking_seconds,
|
||||
// mean_confidence, chunks_appeared_in,
|
||||
// fingerprint_count }, ... }
|
||||
// transcript_segments — array of { start, end, text, speaker,
|
||||
// speaker_confidence } at the RAW Parakeet
|
||||
// segment granularity (finer than the
|
||||
// readable `transcript` text). Recap's
|
||||
// UI matches these by time against the
|
||||
// merged entries to color each line.
|
||||
speakers: finalResult.speakers || null,
|
||||
transcript_segments: Array.isArray(finalResult.transcript_segments)
|
||||
? finalResult.transcript_segments
|
||||
: null,
|
||||
// Phase 2 — speaker name map from the relay's post-cluster
|
||||
// polish pass. Null when polish was skipped or all names
|
||||
// returned null. Recap renders these inline with the
|
||||
// speakers legend (showing "Matt Hill · 24:42" instead of
|
||||
// "Speaker A · 24:42").
|
||||
speaker_names: finalResult.speaker_names || null,
|
||||
};
|
||||
},
|
||||
|
||||
async transcribeAudio({
|
||||
filePath,
|
||||
mimeType,
|
||||
@@ -248,7 +831,7 @@ export function createRelayProvider({
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
if (parsed && (typeof parsed.credits_remaining === "number" || parsed.tier)) {
|
||||
updateRelayState(parsed);
|
||||
updateRelayState(parsed, creditKey);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
@@ -256,7 +839,7 @@ export function createRelayProvider({
|
||||
parsed?.message ||
|
||||
text?.slice(0, 300) ||
|
||||
`HTTP ${res.status}`;
|
||||
recordRelayError(msg);
|
||||
recordRelayError(msg, creditKey);
|
||||
const err = new Error(`Relay /balance ${res.status}: ${msg}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
@@ -264,9 +847,9 @@ export function createRelayProvider({
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
if (err?.name === "AbortError") {
|
||||
recordRelayError(`balance ping timed out after ${timeoutMs}ms`);
|
||||
recordRelayError(`balance ping timed out after ${timeoutMs}ms`, creditKey);
|
||||
} else if (!err.status) {
|
||||
recordRelayError(err?.message || String(err));
|
||||
recordRelayError(err?.message || String(err), creditKey);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
@@ -326,7 +909,231 @@ export function createRelayProvider({
|
||||
raw: envelope,
|
||||
};
|
||||
},
|
||||
|
||||
// Text-to-speech for the audio-first ("walking mode") player. Unlike
|
||||
// the other methods this returns BINARY audio (mp3 by default), with
|
||||
// credit/balance metadata in response HEADERS rather than a JSON
|
||||
// envelope — so it can't reuse postRelay. Mirrors postRelay's
|
||||
// error-envelope harvesting + relay-state update on the JSON error
|
||||
// path. The caller passes ONE jobId for a whole recap so the relay
|
||||
// charges at most 1 credit for synthesizing all its topics.
|
||||
async tts({ text, voice, format = "mp3", jobId, signal } = {}) {
|
||||
const headers = buildHeaders({
|
||||
extra: { "Content-Type": "application/json" },
|
||||
jobId,
|
||||
});
|
||||
let res;
|
||||
try {
|
||||
// Per-clip timeout so one hung synth (e.g. Spark Control busy
|
||||
// transcribing the subscription queue) can't stall the whole
|
||||
// sequential prepare loop — the caller catches and moves on to the
|
||||
// next topic. 90s comfortably exceeds the relay's own ~60s Kokoro
|
||||
// timeout, so the relay's clean error wins when it's the slow one.
|
||||
res = await fetch(`${base}/relay/tts`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ text, voice, format }),
|
||||
signal: signal || AbortSignal.timeout(90_000),
|
||||
});
|
||||
} catch (err) {
|
||||
recordRelayError(err?.message || String(err), creditKey);
|
||||
throw err;
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Errors carry the standard JSON envelope — harvest balance + msg.
|
||||
const errText = await res.text().catch(() => "");
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = errText ? JSON.parse(errText) : null;
|
||||
} catch {}
|
||||
if (parsed && (typeof parsed.credits_remaining === "number" || parsed.tier)) {
|
||||
updateRelayState(parsed, creditKey);
|
||||
}
|
||||
const msg =
|
||||
parsed?.error || parsed?.message || errText?.slice(0, 300) || `HTTP ${res.status}`;
|
||||
const err = new Error(`Relay /relay/tts ${res.status}: ${msg}`);
|
||||
err.status = res.status;
|
||||
err.envelope = parsed;
|
||||
if (!parsed) recordRelayError(msg, creditKey);
|
||||
throw err;
|
||||
}
|
||||
const audio = Buffer.from(await res.arrayBuffer());
|
||||
// Success path: credit state lives in headers. "unlimited" (Max) →
|
||||
// null, matching the JSON envelope's null credits_remaining.
|
||||
const creditsHdr = res.headers.get("X-Recap-Credits-Remaining");
|
||||
const tier = res.headers.get("X-Recap-Tier");
|
||||
const creditCharged = Number(res.headers.get("X-Recap-Credit-Charged") || 0);
|
||||
const creditsRemaining =
|
||||
creditsHdr == null ? null : creditsHdr === "unlimited" ? null : Number(creditsHdr);
|
||||
if (tier || typeof creditsRemaining === "number") {
|
||||
updateRelayState(
|
||||
{ credits_remaining: creditsRemaining, tier, credit_charged: creditCharged },
|
||||
creditKey,
|
||||
);
|
||||
}
|
||||
const durHdr = res.headers.get("X-Recap-Audio-Duration");
|
||||
return {
|
||||
audio,
|
||||
contentType: res.headers.get("Content-Type") || "audio/mpeg",
|
||||
voice: res.headers.get("X-Recap-Tts-Voice") || voice || null,
|
||||
backend: res.headers.get("X-Recap-Tts-Backend") || null,
|
||||
creditCharged,
|
||||
durationSeconds: durHdr ? Number(durHdr) : null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Operator → relay: set / read a cloud user's tier (core-decoupling) ──
|
||||
// The relay is the source of truth for cloud Pro/Max tiers. The operator
|
||||
// grant flow calls these server-to-server, authenticated by the shared
|
||||
// operator key — no per-user license involved.
|
||||
export async function setRelayUserTier({ userId, tier, expiresAt = null, timeoutMs = 10000 }) {
|
||||
const base = getRelayBaseURL();
|
||||
const operatorKey = getRelayOperatorKey();
|
||||
if (!base) throw new Error("relay base URL not configured");
|
||||
if (!operatorKey) {
|
||||
throw new Error("operator key not configured (set RECAP_RELAY_OPERATOR_KEY)");
|
||||
}
|
||||
const res = await fetch(`${base.replace(/\/$/, "")}/relay/user-tier`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Recap-Operator-Key": operatorKey },
|
||||
body: JSON.stringify({ user_id: userId, tier, expires_at: expiresAt || undefined }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = new Error(data?.error || `relay user-tier ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getRelayUserTier({ userId, timeoutMs = 8000 }) {
|
||||
const base = getRelayBaseURL();
|
||||
const operatorKey = getRelayOperatorKey();
|
||||
if (!base || !operatorKey) return null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${base.replace(/\/$/, "")}/relay/user-tier/${encodeURIComponent(userId)}`,
|
||||
{ headers: { "X-Recap-Operator-Key": operatorKey }, signal: AbortSignal.timeout(timeoutMs) }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Ask the relay to create a BTCPay invoice for a prepaid Pro/Max period for
|
||||
// `userId`. Operator-key authed (server-to-server). Returns
|
||||
// { invoice_id, checkout_url, sats, tier, period_days } or throws.
|
||||
export async function createRelayTierInvoice({
|
||||
userId,
|
||||
tier,
|
||||
returnUrl = null,
|
||||
timeoutMs = 12000,
|
||||
}) {
|
||||
const base = getRelayBaseURL();
|
||||
const operatorKey = getRelayOperatorKey();
|
||||
if (!base) throw new Error("relay base URL not configured");
|
||||
if (!operatorKey) {
|
||||
throw new Error("operator key not configured (set RECAP_RELAY_OPERATOR_KEY)");
|
||||
}
|
||||
const res = await fetch(`${base.replace(/\/$/, "")}/relay/tier-invoice`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Recap-Operator-Key": operatorKey },
|
||||
body: JSON.stringify({ user_id: userId, tier, return_url: returnUrl || undefined }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = new Error(data?.error || `relay tier-invoice ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Ask the relay to create a Zaprite (card) hosted-checkout order for a
|
||||
// prepaid Pro/Max period for `userId`. Operator-key authed (server-to-
|
||||
// server), mirroring createRelayTierInvoice but for the card rail. Returns
|
||||
// { order_id, checkout_url, amount, currency, tier, period_days } or throws.
|
||||
export async function createRelayZapriteOrder({
|
||||
userId,
|
||||
tier,
|
||||
returnUrl = null,
|
||||
timeoutMs = 12000,
|
||||
}) {
|
||||
const base = getRelayBaseURL();
|
||||
const operatorKey = getRelayOperatorKey();
|
||||
if (!base) throw new Error("relay base URL not configured");
|
||||
if (!operatorKey) {
|
||||
throw new Error("operator key not configured (set RECAP_RELAY_OPERATOR_KEY)");
|
||||
}
|
||||
const res = await fetch(`${base.replace(/\/$/, "")}/relay/tier-zaprite-order`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Recap-Operator-Key": operatorKey },
|
||||
body: JSON.stringify({ user_id: userId, tier, return_url: returnUrl || undefined }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = new Error(data?.error || `relay tier-zaprite-order ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Read the buyable subscription plans + sats prices from the relay (the
|
||||
// pricing source of truth). Operator-key authed. Returns
|
||||
// { period_days, plans: [{tier, sats}] } or null when the relay is
|
||||
// unreachable / unconfigured (caller falls back to a sane default).
|
||||
export async function getRelayTierPlans({ timeoutMs = 8000 } = {}) {
|
||||
const base = getRelayBaseURL();
|
||||
const operatorKey = getRelayOperatorKey();
|
||||
if (!base || !operatorKey) return null;
|
||||
try {
|
||||
const res = await fetch(`${base.replace(/\/$/, "")}/relay/tier-plans`, {
|
||||
headers: { "X-Recap-Operator-Key": operatorKey },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// List cloud users whose prepaid Pro/Max period expires within
|
||||
// `withinDays` (future) or lapsed within the last `lapsedDays`. Operator-
|
||||
// key authed. The relay owns subscription expiry; Recaps calls this to
|
||||
// decide who to email expiry reminders to. Returns the parsed
|
||||
// { subscriptions: [{user_id, tier, expires_at, expired, days_left}] }
|
||||
// or null when the relay is unreachable / unconfigured.
|
||||
export async function getRelayExpiringSubscriptions({
|
||||
withinDays = 7,
|
||||
lapsedDays = 3,
|
||||
timeoutMs = 10000,
|
||||
} = {}) {
|
||||
const base = getRelayBaseURL();
|
||||
const operatorKey = getRelayOperatorKey();
|
||||
if (!base || !operatorKey) return null;
|
||||
try {
|
||||
const url = new URL(`${base.replace(/\/$/, "")}/relay/expiring-subscriptions`);
|
||||
url.searchParams.set("within_days", String(withinDays));
|
||||
url.searchParams.set("lapsed_days", String(lapsedDays));
|
||||
const res = await fetch(url, {
|
||||
headers: { "X-Recap-Operator-Key": operatorKey },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Streams a file off disk into a Blob with the given MIME type for
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Recap-side cache of the relay's GET /relay/capabilities response.
|
||||
// The relay tells us how large an audio file it can comfortably accept
|
||||
// FOR THIS SPECIFIC INSTALL given the relay's current routing config
|
||||
// AND this install's tier + Gemini-cap state. The same operator config
|
||||
// will route a fresh install through Gemini (chunk at 60min/30MB) and
|
||||
// a cap-exhausted install through hardware (no chunking) — so the
|
||||
// capabilities answer is per-install, not per-operator.
|
||||
//
|
||||
// Recap calls this in three places:
|
||||
// - on boot (warm the cache with safe defaults)
|
||||
// - hourly background refresh (catches operator config edits)
|
||||
// - inline before every relay-backed transcribe (so the chunking
|
||||
// decision matches the routing decision the relay will actually
|
||||
// make for this install RIGHT NOW)
|
||||
//
|
||||
// When the relay is unreachable, we fall back to Gemini-safe defaults
|
||||
// so chunking happens defensively for long audio.
|
||||
|
||||
import { getRelayBaseURL } from "./relay-default.js";
|
||||
import { getInstallId } from "./install-id.js";
|
||||
import { getRawLicenseKey } from "./license.js";
|
||||
|
||||
const REFRESH_INTERVAL_MS = 60 * 60 * 1000; // hourly
|
||||
const FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
// Safe defaults: chunk like we have been. Used until /relay/capabilities
|
||||
// successfully populates the cache, OR if the relay is unreachable.
|
||||
const DEFAULTS = Object.freeze({
|
||||
max_audio_mb: 30,
|
||||
max_audio_minutes: 60,
|
||||
preferred_chunk_seconds: 2700,
|
||||
// Audio-first ("walking mode") TTS availability. The relay advertises
|
||||
// whether ANY TTS backend (Kokoro on operator hardware, or ElevenLabs)
|
||||
// can serve a /relay/tts call. The frontend uses has_tts to decide
|
||||
// whether to show the "Listen" affordance; the prepare route checks it
|
||||
// before attempting synthesis. Conservative default: off until a fetch
|
||||
// confirms it.
|
||||
has_tts: false,
|
||||
tts_backend: null, // "kokoro" | "elevenlabs" | null
|
||||
tts_default_voice: null,
|
||||
reason: "default (relay unreachable or not yet fetched)",
|
||||
fetched_at: 0,
|
||||
});
|
||||
|
||||
let cached = { ...DEFAULTS };
|
||||
let refreshTimer = null;
|
||||
|
||||
export async function refreshRelayCapabilities() {
|
||||
const base = getRelayBaseURL();
|
||||
if (!base) return cached;
|
||||
const url = `${base.replace(/\/$/, "")}/relay/capabilities`;
|
||||
// Send install-id + license so the relay can run the per-install
|
||||
// routing decision. Both are optional from the relay's perspective —
|
||||
// missing install-id falls back to operator-wide capabilities, which
|
||||
// is still safer than nothing.
|
||||
const headers = {};
|
||||
try {
|
||||
const installId = getInstallId();
|
||||
if (installId) headers["X-Recap-Install-Id"] = installId;
|
||||
} catch {}
|
||||
try {
|
||||
const licenseKey = getRawLicenseKey();
|
||||
if (licenseKey) headers["Authorization"] = `Bearer ${licenseKey}`;
|
||||
} catch {}
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!r.ok) {
|
||||
console.warn(`[relay-capabilities] ${url} returned HTTP ${r.status}`);
|
||||
return cached;
|
||||
}
|
||||
const data = await r.json();
|
||||
cached = {
|
||||
max_audio_mb:
|
||||
typeof data.max_audio_mb === "number" ? data.max_audio_mb : DEFAULTS.max_audio_mb,
|
||||
max_audio_minutes:
|
||||
typeof data.max_audio_minutes === "number"
|
||||
? data.max_audio_minutes
|
||||
: DEFAULTS.max_audio_minutes,
|
||||
preferred_chunk_seconds:
|
||||
data.preferred_chunk_seconds === null
|
||||
? null
|
||||
: typeof data.preferred_chunk_seconds === "number"
|
||||
? data.preferred_chunk_seconds
|
||||
: DEFAULTS.preferred_chunk_seconds,
|
||||
has_tts: !!data.has_tts,
|
||||
tts_backend: typeof data.tts_backend === "string" ? data.tts_backend : null,
|
||||
tts_default_voice:
|
||||
typeof data.tts_default_voice === "string" ? data.tts_default_voice : null,
|
||||
reason: typeof data.reason === "string" ? data.reason : null,
|
||||
fetched_at: Date.now(),
|
||||
};
|
||||
console.log(
|
||||
`[relay-capabilities] refreshed: ${cached.max_audio_mb}MB / ${cached.max_audio_minutes}min / chunk=${cached.preferred_chunk_seconds}s (${cached.reason})`
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`[relay-capabilities] fetch failed: ${err?.message || err}`);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function startRelayCapabilitiesRefresh() {
|
||||
// Fire-and-forget first refresh on boot; schedule hourly thereafter.
|
||||
refreshRelayCapabilities().catch(() => {});
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
refreshTimer = setInterval(() => {
|
||||
refreshRelayCapabilities().catch(() => {});
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function getRelayCapabilities() {
|
||||
return cached;
|
||||
}
|
||||
+20
-1
@@ -10,9 +10,28 @@
|
||||
// Override at runtime via the RECAP_RELAY_BASE_URL env var for local
|
||||
// dev testing only — there's no StartOS action exposed for this, so
|
||||
// production installs always use the hardcoded value.
|
||||
const DEFAULT_RELAY_BASE_URL = "https://relay.keysat.xyz";
|
||||
import { relayOperatorKey } from "./config.js";
|
||||
|
||||
const DEFAULT_RELAY_BASE_URL = "https://relay.recaps.cc";
|
||||
|
||||
export function getRelayBaseURL() {
|
||||
const fromEnv = (process.env.RECAP_RELAY_BASE_URL || "").trim();
|
||||
return fromEnv || DEFAULT_RELAY_BASE_URL;
|
||||
}
|
||||
|
||||
// Shared "operator key" (core-decoupling): the secret that authenticates
|
||||
// THIS cloud Recaps server to the relay so it can vouch for its signed-in
|
||||
// users by account-id (X-Recap-User-Id) instead of attaching a per-user
|
||||
// Keysat license. Set the SAME value in the relay's relay_cloud_operator_key.
|
||||
// Empty = cloud user-id identity is disabled; paid users fall back to the
|
||||
// operator pool. Server-side only — never sent to the browser.
|
||||
//
|
||||
// Resolution: the RECAP_RELAY_OPERATOR_KEY env var pins it (local dev);
|
||||
// otherwise the value comes from the StartOS config sidecar via config.js's
|
||||
// polled `relayOperatorKey` live binding (set by the "Set Relay Operator
|
||||
// Key" action, picked up within one poll — no restart needed).
|
||||
export function getRelayOperatorKey() {
|
||||
const fromEnv = (process.env.RECAP_RELAY_OPERATOR_KEY || "").trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
return (relayOperatorKey || "").trim();
|
||||
}
|
||||
|
||||
+107
-42
@@ -1,52 +1,117 @@
|
||||
// In-memory cache of the most recent relay-reported credit balance + tier.
|
||||
// Updated every time a relay provider call lands (success or 4xx error
|
||||
// that includes the standard envelope). Exposed via /api/relay/status
|
||||
// so the UI can render the "N credits remaining · Tier: X" banner
|
||||
// without re-hitting the relay just for status.
|
||||
// In-memory cache of the most recent relay-reported credit balance + tier,
|
||||
// keyed by credit-key so each distinct (installId, license) pair has its
|
||||
// own snapshot. Multi-mode introduces multiple identities in one Recap
|
||||
// process (operator's pool + each paid user's license), and a single
|
||||
// global snapshot would let one user's relay ping clobber another's
|
||||
// cached view.
|
||||
//
|
||||
// Not persisted to disk — the relay is the source of truth. We just cache
|
||||
// the last response so the UI doesn't have to wait for the next request
|
||||
// to refresh the display. On a fresh boot the cache is empty until the
|
||||
// first /api/relay/status call, which can optionally probe the relay.
|
||||
// Credit-key shape mirrors the relay's own keying (Path 3 license-keyed
|
||||
// credits): `lic:<sha256(licenseKey).slice(0, 16)>` for paid licenses,
|
||||
// `inst:<installId>` for unlicensed / Core. Stable per identity, doesn't
|
||||
// require parsing the license payload — we hash the raw LIC1 string,
|
||||
// which works as a local cache key independent of the relay's exact
|
||||
// fingerprint formula.
|
||||
//
|
||||
// Not persisted to disk — relay is the source of truth. On a fresh boot
|
||||
// the map is empty until each request triggers its first probe.
|
||||
|
||||
let lastSnapshot = {
|
||||
creditsRemaining: null, // number | null
|
||||
tier: null, // "core" | "pro" | "max" | null
|
||||
lastUpdated: null, // ms-epoch | null
|
||||
lastError: null, // string | null
|
||||
};
|
||||
import { createHash } from "crypto";
|
||||
|
||||
// Called by the relay provider on every response (including error
|
||||
// responses that the relay annotated with the standard envelope).
|
||||
// `envelope` is the parsed JSON shape: { credits_remaining, tier, ... }.
|
||||
export function updateRelayState(envelope) {
|
||||
if (!envelope || typeof envelope !== "object") return;
|
||||
if (typeof envelope.credits_remaining === "number") {
|
||||
lastSnapshot.creditsRemaining = envelope.credits_remaining;
|
||||
}
|
||||
if (typeof envelope.tier === "string") {
|
||||
lastSnapshot.tier = envelope.tier;
|
||||
}
|
||||
lastSnapshot.lastUpdated = Date.now();
|
||||
lastSnapshot.lastError = null;
|
||||
}
|
||||
// Map<creditKey, snapshot>. snapshot shape:
|
||||
// { creditsRemaining, tier, lastUpdated, lastError }
|
||||
const snapshots = new Map();
|
||||
|
||||
// Record a relay error (network failure, 5xx with no envelope, etc.).
|
||||
// Surfaced in the UI status so the user knows the balance display is stale.
|
||||
export function recordRelayError(message) {
|
||||
lastSnapshot.lastError = (message || "Unknown relay error").slice(0, 300);
|
||||
lastSnapshot.lastUpdated = Date.now();
|
||||
}
|
||||
// LRU-ish defensive cap so a worst-case (e.g. a buggy probe spinning up
|
||||
// thousands of distinct identities) can't grow the map unbounded. In
|
||||
// normal use we expect on the order of (1 operator + N paid users)
|
||||
// rows, well under this ceiling.
|
||||
const MAX_SNAPSHOTS = 5000;
|
||||
|
||||
export function getRelayState() {
|
||||
return { ...lastSnapshot };
|
||||
}
|
||||
|
||||
export function resetRelayState() {
|
||||
lastSnapshot = {
|
||||
const EMPTY_SNAPSHOT = Object.freeze({
|
||||
creditsRemaining: null,
|
||||
tier: null,
|
||||
lastUpdated: null,
|
||||
lastError: null,
|
||||
};
|
||||
});
|
||||
|
||||
// computeCreditKey({ installId, licenseKey, userId }) — derives the stable
|
||||
// per-identity key used by both the provider (when recording responses)
|
||||
// and the /api/relay/status handler (when looking up the right snapshot
|
||||
// for the request). Priority: cloud userId (core-decoupling `user:<id>`)
|
||||
// → license (hashed raw key) → install_id. Returns null if all three are
|
||||
// missing (caller should treat as "no cache, never hit" and skip the
|
||||
// lookup).
|
||||
export function computeCreditKey({ installId, licenseKey, userId } = {}) {
|
||||
// Cloud (core-decoupling) identity: keyed by the Recaps account id, to
|
||||
// mirror the relay's `user:<id>` pool.
|
||||
const uid = (userId || "").trim();
|
||||
if (uid) return "user:" + uid;
|
||||
const lic = (licenseKey || "").trim();
|
||||
if (lic) {
|
||||
return "lic:" + createHash("sha256").update(lic).digest("hex").slice(0, 16);
|
||||
}
|
||||
const id = (installId || "").trim();
|
||||
if (id) return "inst:" + id;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOrCreate(creditKey) {
|
||||
let s = snapshots.get(creditKey);
|
||||
if (!s) {
|
||||
if (snapshots.size >= MAX_SNAPSHOTS) {
|
||||
// Evict the oldest entry (Map iteration is insertion-ordered).
|
||||
const oldest = snapshots.keys().next().value;
|
||||
if (oldest != null) snapshots.delete(oldest);
|
||||
}
|
||||
s = { creditsRemaining: null, tier: null, lastUpdated: null, lastError: null };
|
||||
snapshots.set(creditKey, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Called by the relay provider on every response (including 4xx with the
|
||||
// standard envelope). `envelope` is the parsed JSON shape:
|
||||
// { credits_remaining, tier, ... }
|
||||
// `creditKey` is the per-identity key from computeCreditKey(); required
|
||||
// in multi-mode, optional in single-mode (falls through to a synthetic
|
||||
// "operator" bucket — see fallback below).
|
||||
export function updateRelayState(envelope, creditKey) {
|
||||
if (!envelope || typeof envelope !== "object") return;
|
||||
const key = creditKey || "operator";
|
||||
const s = getOrCreate(key);
|
||||
if (typeof envelope.credits_remaining === "number") {
|
||||
s.creditsRemaining = envelope.credits_remaining;
|
||||
}
|
||||
if (typeof envelope.tier === "string") {
|
||||
s.tier = envelope.tier;
|
||||
}
|
||||
s.lastUpdated = Date.now();
|
||||
s.lastError = null;
|
||||
}
|
||||
|
||||
// Record a relay error (network failure, 5xx without envelope, etc.).
|
||||
// Same keying as updateRelayState — the error sticks to the identity
|
||||
// that hit it, so an admin's tile doesn't flash "relay unreachable"
|
||||
// because a tenant's probe failed.
|
||||
export function recordRelayError(message, creditKey) {
|
||||
const key = creditKey || "operator";
|
||||
const s = getOrCreate(key);
|
||||
s.lastError = (message || "Unknown relay error").slice(0, 300);
|
||||
s.lastUpdated = Date.now();
|
||||
}
|
||||
|
||||
// getRelayState(creditKey) — fetch the snapshot for an identity. Returns
|
||||
// the EMPTY_SNAPSHOT (with nulls) if the identity has never recorded
|
||||
// anything yet. Caller can branch on creditsRemaining === null vs
|
||||
// numeric to decide whether to probe.
|
||||
export function getRelayState(creditKey) {
|
||||
const key = creditKey || "operator";
|
||||
const s = snapshots.get(key);
|
||||
return s ? { ...s } : { ...EMPTY_SNAPSHOT };
|
||||
}
|
||||
|
||||
// Test/teardown helper. Clears every cached snapshot. Used by tests + a
|
||||
// debugging admin endpoint if we ever need one. Not used in production.
|
||||
export function resetRelayState() {
|
||||
snapshots.clear();
|
||||
}
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
// SMTP transport for outbound mail (currently: magic-link sign-in
|
||||
// links). Credentials come from /data/config/startos-config.json,
|
||||
// which startos/main.ts keeps in sync with the StartOS System SMTP
|
||||
// config via effects.getSystemSmtp({callback}).
|
||||
//
|
||||
// We poll the same JSON the rest of the server reads (via
|
||||
// getConfigSnapshot()) and rebuild the nodemailer transport whenever
|
||||
// the credentials change. Rebuilds are cheap — nodemailer's
|
||||
// createTransport is synchronous and just stashes options. The actual
|
||||
// SMTP connection is opened per-send (or pooled), not at transport
|
||||
// creation.
|
||||
//
|
||||
// Only used in multi-tenant mode. Single mode never imports this.
|
||||
|
||||
import nodemailer from "nodemailer";
|
||||
import { getConfigSnapshot } from "./config.js";
|
||||
|
||||
let cachedTransport = null;
|
||||
let cachedFingerprint = ""; // last seen credentials as a stable string
|
||||
let cachedFrom = "";
|
||||
|
||||
const POLL_MS = 3000;
|
||||
|
||||
// initSmtp(): kicks off the credentials-watch loop. Idempotent in
|
||||
// effect (later calls are harmless), but you only need it once per
|
||||
// process lifetime.
|
||||
export function initSmtp() {
|
||||
refreshTransport().catch((err) => {
|
||||
console.warn("[smtp] initial refresh failed:", err);
|
||||
});
|
||||
setInterval(() => {
|
||||
refreshTransport().catch(() => {});
|
||||
}, POLL_MS);
|
||||
}
|
||||
|
||||
async function refreshTransport() {
|
||||
const snap = await getConfigSnapshot();
|
||||
const host = snap.smtp_host || "";
|
||||
const port = parseInt(snap.smtp_port || 0, 10) || 0;
|
||||
const security = snap.smtp_security || "tls";
|
||||
const username = snap.smtp_username || "";
|
||||
const password = snap.smtp_password || "";
|
||||
const from = snap.smtp_from || "";
|
||||
|
||||
// Fingerprint covers every field that affects the transport. If
|
||||
// nothing changed, leave the existing transport in place — avoids
|
||||
// tearing down a pooled connection on every poll tick.
|
||||
const fingerprint = [host, port, security, username, password, from].join(
|
||||
"\x1f",
|
||||
);
|
||||
if (fingerprint === cachedFingerprint) return;
|
||||
|
||||
cachedFingerprint = fingerprint;
|
||||
cachedFrom = from;
|
||||
|
||||
if (!host || !port) {
|
||||
if (cachedTransport) {
|
||||
cachedTransport.close?.();
|
||||
cachedTransport = null;
|
||||
console.log("[smtp] credentials cleared, transport closed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// security mapping:
|
||||
// - "tls" → implicit TLS (SMTPS, typically port 465). secure=true.
|
||||
// - "starttls" → cleartext then STARTTLS (typically port 587). secure=false,
|
||||
// requireTLS=true so we refuse to silently fall back to plain.
|
||||
const isImplicitTls = security === "tls";
|
||||
const transportOpts = {
|
||||
host,
|
||||
port,
|
||||
secure: isImplicitTls,
|
||||
requireTLS: !isImplicitTls, // forces STARTTLS when secure=false
|
||||
auth: username ? { user: username, pass: password } : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const next = nodemailer.createTransport(transportOpts);
|
||||
if (cachedTransport) cachedTransport.close?.();
|
||||
cachedTransport = next;
|
||||
console.log(
|
||||
`[smtp] transport built host=${host} port=${port} security=${security} user=${username || "(none)"}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("[smtp] createTransport failed:", err);
|
||||
cachedTransport = null;
|
||||
}
|
||||
}
|
||||
|
||||
// sendMail({to, subject, text, html})
|
||||
// Returns nodemailer's info object on success. Throws if the
|
||||
// transport isn't configured — callers should treat that as "SMTP
|
||||
// not set up yet, ask the operator to configure StartOS System SMTP."
|
||||
export async function sendMail({ to, subject, text, html }) {
|
||||
if (!cachedTransport) {
|
||||
throw new Error("smtp_not_configured");
|
||||
}
|
||||
if (!cachedFrom) {
|
||||
throw new Error("smtp_from_not_set");
|
||||
}
|
||||
return await cachedTransport.sendMail({
|
||||
from: cachedFrom,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
// True iff the transport is built and ready to send. The auth handlers
|
||||
// use this to gate magic-link requests with a useful error rather than
|
||||
// letting nodemailer's "no transport" leak through.
|
||||
export function isSmtpReady() {
|
||||
return cachedTransport !== null && cachedFrom !== "";
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Self-serve subscription expiry reminders (multi-mode / cloud only).
|
||||
//
|
||||
// The relay owns each user's prepaid-period expiry (subscription source of
|
||||
// truth); Recaps owns the email address + the recaps.cc SMTP transport. So
|
||||
// a daily scan here asks the relay who's expiring (or just lapsed), maps
|
||||
// user_id → users.email, and sends a reminder via the same sendMail() the
|
||||
// magic-link flow uses. The subscription_reminders table dedups so each
|
||||
// (user, period, kind) email goes out at most once; a renewal changes the
|
||||
// expiry instant, which re-arms a fresh set of reminders for the new period.
|
||||
//
|
||||
// Self-gating: no-ops unless SMTP is configured, the public URL is set, and
|
||||
// the relay is reachable. Safe to start unconditionally in multi mode.
|
||||
|
||||
import { getDb } from "./db.js";
|
||||
import { sendMail, isSmtpReady } from "./smtp.js";
|
||||
import { renderSubscriptionReminderEmail } from "./email-template.js";
|
||||
import { getRelayExpiringSubscriptions } from "./providers/relay.js";
|
||||
import { getConfigSnapshot } from "./config.js";
|
||||
|
||||
// Reminder thresholds, in days before expiry. Each maps to a distinct
|
||||
// `kind` so a user gets one heads-up at each crossing (deduped by kind).
|
||||
const UPCOMING_THRESHOLDS = [
|
||||
{ days: 7, kind: "upcoming_7d" },
|
||||
{ days: 1, kind: "upcoming_1d" },
|
||||
];
|
||||
const LAPSED_KIND = "lapsed";
|
||||
// Only email recently-lapsed users; after this they age out of the relay
|
||||
// window (and we never re-send the same period's lapsed notice anyway).
|
||||
const LAPSED_WINDOW_DAYS = 3;
|
||||
|
||||
const SCAN_INTERVAL_MS = 12 * 60 * 60 * 1000; // twice a day
|
||||
const BOOT_DELAY_MS = 90 * 1000; // first scan ~90s after boot
|
||||
|
||||
let scanning = false;
|
||||
let scheduled = false;
|
||||
|
||||
// Decide which single reminder kind (if any) applies to a subscription
|
||||
// right now. Pure — exported for testing. `sub` is a relay row:
|
||||
// { tier, expires_at, expired, days_left }.
|
||||
export function reminderKindFor(
|
||||
sub,
|
||||
{ upcoming = UPCOMING_THRESHOLDS, lapsedWindowDays = LAPSED_WINDOW_DAYS } = {},
|
||||
) {
|
||||
if (!sub || typeof sub.days_left !== "number") return null;
|
||||
const daysLeft = sub.days_left;
|
||||
if (sub.expired) {
|
||||
// Recently lapsed → a single lapsed notice (days_left is <= 0).
|
||||
return daysLeft >= -lapsedWindowDays ? LAPSED_KIND : null;
|
||||
}
|
||||
// Upcoming: smallest threshold the sub has crossed wins, so daysLeft=1
|
||||
// sends 'upcoming_1d' while daysLeft in (1, 7] sends 'upcoming_7d'.
|
||||
const sorted = [...upcoming].sort((a, b) => a.days - b.days);
|
||||
for (const t of sorted) {
|
||||
if (daysLeft <= t.days) return t.kind;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function alreadySent(db, userId, periodIso, kind) {
|
||||
return !!db
|
||||
.prepare(
|
||||
"SELECT 1 FROM subscription_reminders WHERE user_id=? AND period_expires_at=? AND kind=?",
|
||||
)
|
||||
.get(userId, periodIso, kind);
|
||||
}
|
||||
|
||||
function recordSent(db, userId, periodIso, kind) {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO subscription_reminders (user_id, period_expires_at, kind, sent_at) VALUES (?,?,?,?)",
|
||||
).run(userId, periodIso, kind, Date.now());
|
||||
}
|
||||
|
||||
function maskEmail(email) {
|
||||
return String(email).replace(/^(.).*(@.*)$/, "$1***$2");
|
||||
}
|
||||
|
||||
// One scan pass. Returns a small summary object; never throws (logs +
|
||||
// returns a {skipped} reason instead) so the scheduler stays alive.
|
||||
export async function runReminderScan({ force = false } = {}) {
|
||||
if (scanning && !force) return { skipped: "already_running" };
|
||||
scanning = true;
|
||||
try {
|
||||
if (!isSmtpReady()) return { skipped: "smtp_not_ready" };
|
||||
const snap = await getConfigSnapshot();
|
||||
const publicUrl = (snap.recap_public_url || "").trim().replace(/\/$/, "");
|
||||
if (!publicUrl) return { skipped: "public_url_not_set" };
|
||||
|
||||
const maxDays = Math.max(1, ...UPCOMING_THRESHOLDS.map((t) => t.days));
|
||||
const report = await getRelayExpiringSubscriptions({
|
||||
withinDays: maxDays,
|
||||
lapsedDays: LAPSED_WINDOW_DAYS,
|
||||
});
|
||||
if (!report || !Array.isArray(report.subscriptions)) {
|
||||
return { skipped: "relay_unavailable" };
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const manageUrl = `${publicUrl}/?renew=1`;
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
for (const sub of report.subscriptions) {
|
||||
const kind = reminderKindFor(sub);
|
||||
if (!kind) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const periodIso = sub.expires_at;
|
||||
if (!periodIso || alreadySent(db, sub.user_id, periodIso, kind)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const u = db
|
||||
.prepare("SELECT email FROM users WHERE id=?")
|
||||
.get(sub.user_id);
|
||||
const email = (u?.email || "").trim();
|
||||
if (!email) {
|
||||
// No local user for this id (e.g. another instance's tenant) — skip.
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const message = renderSubscriptionReminderEmail({
|
||||
brandName: "Recaps",
|
||||
tier: sub.tier,
|
||||
expiresAt: sub.expires_at,
|
||||
daysLeft: sub.days_left,
|
||||
kind,
|
||||
manageUrl,
|
||||
});
|
||||
try {
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: message.subject,
|
||||
text: message.text,
|
||||
html: message.html,
|
||||
});
|
||||
recordSent(db, sub.user_id, periodIso, kind);
|
||||
sent++;
|
||||
console.log(
|
||||
`[reminders] sent ${kind} to ${maskEmail(email)} (${sub.tier}, ${sub.days_left}d)`,
|
||||
);
|
||||
} catch (err) {
|
||||
// Don't record on failure → retried next scan.
|
||||
console.warn(
|
||||
`[reminders] sendMail failed for ${sub.user_id}: ${err?.message || err}`,
|
||||
);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
if (sent) {
|
||||
console.log(`[reminders] scan complete: ${sent} sent, ${skipped} skipped`);
|
||||
}
|
||||
return { sent, skipped };
|
||||
} catch (err) {
|
||||
console.warn(`[reminders] scan error: ${err?.message || err}`);
|
||||
return { skipped: "error", error: err?.message || String(err) };
|
||||
} finally {
|
||||
scanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Start the daily-ish scan loop. Idempotent. Self-gates inside the scan,
|
||||
// so it's safe to call whenever multi mode boots.
|
||||
export function startReminderScheduler() {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
setTimeout(() => {
|
||||
runReminderScan().catch(() => {});
|
||||
}, BOOT_DELAY_MS);
|
||||
setInterval(() => {
|
||||
runReminderScan().catch(() => {});
|
||||
}, SCAN_INTERVAL_MS);
|
||||
console.log("[reminders] expiry-reminder scheduler started");
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// Subscription storage + discovery helpers, keyed by scope.
|
||||
//
|
||||
// Today the subscription feature is operator-only in multi mode (gated in
|
||||
// license-middleware), so the only active scope is "owner". But the storage,
|
||||
// dedup, check-enumeration, and migration here are all scope-parameterized,
|
||||
// so flipping the gate to per-tenant (see docs/per-tenant-subscriptions-plan.md)
|
||||
// is a matter of passing each user's scope instead of "owner" — no storage
|
||||
// rework. Step 4 of that plan (the background processor acting as each
|
||||
// owning user) is the only remaining piece and needs on-device testing.
|
||||
//
|
||||
// Each scope's state lives under scopeDir(scope) = history/<scope>/:
|
||||
// subscriptions.json — the user's channel/podcast subscriptions
|
||||
// auto-queue.json — discovered videos awaiting approval / processing
|
||||
// skip-list.json — videoIds the user declined (never re-offer)
|
||||
// seen-list.json — videoIds already offered (don't re-surface)
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { getScopeHistoryDir, getHistoryDir, ROOT_SIDECARS } from "./history.js";
|
||||
|
||||
// ── Per-scope file paths ─────────────────────────────────────────────────
|
||||
function subsPath(scope) {
|
||||
return path.join(getScopeHistoryDir(scope), "subscriptions.json");
|
||||
}
|
||||
function skipPath(scope) {
|
||||
return path.join(getScopeHistoryDir(scope), "skip-list.json");
|
||||
}
|
||||
function seenPath(scope) {
|
||||
return path.join(getScopeHistoryDir(scope), "seen-list.json");
|
||||
}
|
||||
function autoQueuePath(scope) {
|
||||
return path.join(getScopeHistoryDir(scope), "auto-queue.json");
|
||||
}
|
||||
|
||||
async function ensureScopeDir(scope) {
|
||||
await fs.mkdir(getScopeHistoryDir(scope), { recursive: true }).catch(() => {});
|
||||
}
|
||||
|
||||
// Serialize read-modify-write on a given file path so two concurrent
|
||||
// handlers can't each load the same snapshot, mutate, and have the second
|
||||
// write clobber the first. Keyed by absolute path → naturally per-scope.
|
||||
const _fileLocks = new Map();
|
||||
function withFileLock(key, fn) {
|
||||
const prev = _fileLocks.get(key) || Promise.resolve();
|
||||
const next = prev.then(fn, fn); // run fn whether prev resolved or rejected
|
||||
_fileLocks.set(
|
||||
key,
|
||||
next.catch(() => {}),
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
// ── Subscriptions ────────────────────────────────────────────────────────
|
||||
export async function loadSubscriptions(scope) {
|
||||
try {
|
||||
return (
|
||||
JSON.parse(await fs.readFile(subsPath(scope), "utf-8")).subscriptions || []
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export async function saveSubscriptions(scope, subs) {
|
||||
return withFileLock(subsPath(scope), async () => {
|
||||
await ensureScopeDir(scope);
|
||||
await fs.writeFile(
|
||||
subsPath(scope),
|
||||
JSON.stringify({ subscriptions: subs }, null, 2),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Skip list (declined videos — never re-add) ───────────────────────────
|
||||
export async function loadSkipList(scope) {
|
||||
try {
|
||||
return new Set(
|
||||
JSON.parse(await fs.readFile(skipPath(scope), "utf-8")).videoIds || [],
|
||||
);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
export async function addToSkipList(scope, videoId) {
|
||||
return withFileLock(skipPath(scope), async () => {
|
||||
const ids = await loadSkipList(scope);
|
||||
ids.add(videoId);
|
||||
await ensureScopeDir(scope);
|
||||
await fs.writeFile(skipPath(scope), JSON.stringify({ videoIds: [...ids] }));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Seen list (already offered — don't re-surface) ───────────────────────
|
||||
export async function loadSeenList(scope) {
|
||||
try {
|
||||
return new Set(
|
||||
JSON.parse(await fs.readFile(seenPath(scope), "utf-8")).videoIds || [],
|
||||
);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
export async function addToSeenList(scope, videoIds) {
|
||||
return withFileLock(seenPath(scope), async () => {
|
||||
const seen = await loadSeenList(scope);
|
||||
for (const id of videoIds) seen.add(id);
|
||||
await ensureScopeDir(scope);
|
||||
await fs.writeFile(seenPath(scope), JSON.stringify({ videoIds: [...seen] }));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Auto-queue ───────────────────────────────────────────────────────────
|
||||
// Read-only load. For mutations use mutateAutoQueue so the read-modify-write
|
||||
// is atomic per scope (the old in-memory global array gave this implicitly).
|
||||
export async function loadAutoQueue(scope) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(autoQueuePath(scope), "utf-8")).items || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export async function saveAutoQueue(scope, items) {
|
||||
return withFileLock(autoQueuePath(scope), async () => {
|
||||
await ensureScopeDir(scope);
|
||||
await fs.writeFile(
|
||||
autoQueuePath(scope),
|
||||
JSON.stringify({ items }, null, 2),
|
||||
);
|
||||
});
|
||||
}
|
||||
// Atomic read-modify-write. `fn(items)` may mutate `items` in place and/or
|
||||
// return a replacement array. Returns the saved array. Use for every
|
||||
// status change / add / remove so concurrent handlers don't lose updates.
|
||||
export async function mutateAutoQueue(scope, fn) {
|
||||
return withFileLock(autoQueuePath(scope), async () => {
|
||||
let items = [];
|
||||
try {
|
||||
items =
|
||||
JSON.parse(await fs.readFile(autoQueuePath(scope), "utf-8")).items || [];
|
||||
} catch {}
|
||||
const result = await fn(items);
|
||||
const toSave = Array.isArray(result) ? result : items;
|
||||
await ensureScopeDir(scope);
|
||||
await fs.writeFile(
|
||||
autoQueuePath(scope),
|
||||
JSON.stringify({ items: toSave }, null, 2),
|
||||
);
|
||||
return toSave;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Dedup ────────────────────────────────────────────────────────────────
|
||||
// All videoIds already summarized in a scope's library.
|
||||
//
|
||||
// CRITICAL: summaries live under scopeDir(scope) = history/<scope>/, NOT the
|
||||
// top-level history dir. Scanning the top level (the historical bug) found
|
||||
// zero processed videos, so the subscription check never deduped against the
|
||||
// library and re-queued already-summarized videos every run.
|
||||
export async function getProcessedVideoIds(scope = "owner") {
|
||||
const ids = new Set();
|
||||
const dir = getScopeHistoryDir(scope);
|
||||
try {
|
||||
const files = await fs.readdir(dir);
|
||||
for (const file of files.filter(
|
||||
(f) => f.endsWith(".json") && !ROOT_SIDECARS.has(f),
|
||||
)) {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(dir, file), "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
if (data.videoId) ids.add(data.videoId);
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Pure dedup predicate: is this discovered video already accounted for?
|
||||
// Known = in the library (processed), already queued, declined (skip), or
|
||||
// offered before (seen). Used identically by the podcast + YouTube branches.
|
||||
export function isKnownVideo(
|
||||
id,
|
||||
{ processedIds, queuedIds, skippedIds, seenIds } = {},
|
||||
) {
|
||||
return !!(
|
||||
(processedIds && processedIds.has(id)) ||
|
||||
(queuedIds && queuedIds.has(id)) ||
|
||||
(skippedIds && skippedIds.has(id)) ||
|
||||
(seenIds && seenIds.has(id))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scope enumeration (for the periodic check loop) ──────────────────────
|
||||
// Scopes that have at least one subscription. Always includes "owner" (the
|
||||
// operator). Behind the operator-only gate this returns just ["owner"]; when
|
||||
// per-tenant subscriptions ship it picks up each tenant scope automatically.
|
||||
export async function listSubscriptionScopes() {
|
||||
const root = getHistoryDir();
|
||||
const scopes = new Set(["owner"]);
|
||||
try {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory() || e.name === "owner") continue;
|
||||
try {
|
||||
const subs =
|
||||
JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(root, e.name, "subscriptions.json"),
|
||||
"utf-8",
|
||||
),
|
||||
).subscriptions || [];
|
||||
if (subs.length > 0) scopes.add(e.name);
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return [...scopes];
|
||||
}
|
||||
|
||||
// Scopes that have a non-empty auto-queue. The background processor walks
|
||||
// these to find approved items across all owners (a scope can have queued
|
||||
// items even after its subscriptions were deleted, so this is a superset of
|
||||
// listSubscriptionScopes for processing purposes).
|
||||
export async function listAutoQueueScopes() {
|
||||
const root = getHistoryDir();
|
||||
const scopes = new Set(["owner"]);
|
||||
try {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory() || e.name === "owner") continue;
|
||||
try {
|
||||
const items =
|
||||
JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(root, e.name, "auto-queue.json"),
|
||||
"utf-8",
|
||||
),
|
||||
).items || [];
|
||||
if (items.length > 0) scopes.add(e.name);
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return [...scopes];
|
||||
}
|
||||
|
||||
// ── Migration: history-root globals → owner scope (one-time, idempotent) ──
|
||||
// The pre-0.2.147 layout kept subscription state at the history root (one
|
||||
// install-wide store). Move it under the operator's own scope so the
|
||||
// storage is uniformly per-scope. Only moves a file if the source exists
|
||||
// and the destination doesn't (never clobbers).
|
||||
const SUB_FILES = [
|
||||
"subscriptions.json",
|
||||
"auto-queue.json",
|
||||
"skip-list.json",
|
||||
"seen-list.json",
|
||||
];
|
||||
export async function migrateGlobalSubscriptionsToOwner() {
|
||||
const root = getHistoryDir();
|
||||
const ownerDir = getScopeHistoryDir("owner");
|
||||
let moved = 0;
|
||||
for (const f of SUB_FILES) {
|
||||
const from = path.join(root, f);
|
||||
const to = path.join(ownerDir, f);
|
||||
try {
|
||||
await fs.access(from);
|
||||
} catch {
|
||||
continue; // source missing → nothing to move
|
||||
}
|
||||
try {
|
||||
await fs.access(to);
|
||||
continue; // dest already exists → don't clobber
|
||||
} catch {}
|
||||
try {
|
||||
await fs.mkdir(ownerDir, { recursive: true });
|
||||
await fs.rename(from, to);
|
||||
moved++;
|
||||
} catch {
|
||||
// rename can fail across devices — fall back to copy + unlink.
|
||||
try {
|
||||
await fs.mkdir(ownerDir, { recursive: true });
|
||||
await fs.copyFile(from, to);
|
||||
await fs.unlink(from);
|
||||
moved++;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// Tenant (multi-user) auth middleware.
|
||||
//
|
||||
// Two modes:
|
||||
// - single (the original self-hosted experience): no-op. Sets
|
||||
// req.userId = "owner" so downstream code can scope everything
|
||||
// uniformly to a single synthetic user.
|
||||
// - multi (cloud / family-share): validates the recap_session cookie
|
||||
// against the sessions table, looks up the user, attaches both to
|
||||
// req. Non-public paths without a valid session get a 401.
|
||||
//
|
||||
// This is layered AFTER the admin-auth middleware (which gates /api/*
|
||||
// behind the operator's password in single mode). The two are
|
||||
// orthogonal:
|
||||
// - admin-auth: "is the OPERATOR allowed in?" (single mode only)
|
||||
// - tenant-auth: "which user is this request from?" (multi mode only)
|
||||
// In multi mode the admin-auth gate is disabled — tenant accounts
|
||||
// authenticate themselves directly.
|
||||
|
||||
import { getDb } from "./db.js";
|
||||
import {
|
||||
TRIAL_COOKIE,
|
||||
lookupTrial,
|
||||
hasTrialBudget,
|
||||
} from "./anon-trial.js";
|
||||
|
||||
const SESSION_COOKIE = "recap_session";
|
||||
|
||||
// Endpoints that must remain reachable WITHOUT a session — the auth
|
||||
// flow itself, health check, static assets, the BTCPay webhook. The
|
||||
// frontend's static files are served outside the /api/* tree so they
|
||||
// bypass this middleware entirely; this list only matters for /api/*
|
||||
// and /auth/* paths.
|
||||
const PUBLIC_PATH_PREFIXES = [
|
||||
"/auth/", // /auth/request-link, /auth/verify, /auth/signout
|
||||
"/api/health",
|
||||
"/api/auth/", // future client-facing auth shims (CSRF token issue, etc.)
|
||||
"/api/btcpay/webhook", // BTCPay needs to reach this without a session
|
||||
"/api/network-mode", // returns lan-vs-local; safe to expose
|
||||
"/api/relay/status", // public relay capabilities — pre-trial visibility
|
||||
"/api/account/whoami", // returns state — anonymous visitors must call this
|
||||
// License-status family — anonymous visitors must call these on page
|
||||
// load to render the right header/badge. Multi-mode handlers branch
|
||||
// on req.user/req.trial to return per-user views.
|
||||
"/api/license-status",
|
||||
"/api/install-id",
|
||||
// Credit-purchase family — accepts both signed-in users AND anon
|
||||
// trial cookies (the buy handler routes credits to the right local
|
||||
// balance based on which identity made the call). Each handler
|
||||
// validates buyer presence inline, so leaving these "public" is
|
||||
// safe — it just defers the auth check from the middleware to the
|
||||
// handler where buyer-type-specific logic lives anyway.
|
||||
"/api/credits/",
|
||||
// License purchase + poll — the 3-tier signup modal lets anon
|
||||
// visitors buy a Pro/Max license at the same moment they create
|
||||
// their account. /policies is a passthrough to Keysat's public
|
||||
// /v1/products/.../policies (no auth needed there). /purchase
|
||||
// accepts an anon buyer_email and records a pending_signups row
|
||||
// so the poll-settle handler can create the user + attach the
|
||||
// license + send a magic-link email. /poll just reads invoice
|
||||
// status from Keysat — same trust model as the credit flow.
|
||||
//
|
||||
// Note: /api/license/activate and /api/license/deactivate are
|
||||
// NOT in this prefix (they're operator-only single-mode endpoints
|
||||
// that write /data/license.txt; tenants shouldn't reach them).
|
||||
"/api/license/policies",
|
||||
"/api/license/purchase",
|
||||
"/api/license/poll/",
|
||||
];
|
||||
|
||||
// Paths where an unauthenticated visitor is allowed to obtain (or use)
|
||||
// an anonymous-trial cookie. Restricting trials to the actual
|
||||
// "use the product" endpoint keeps bots that scrape the homepage from
|
||||
// minting trial rows just by visiting /.
|
||||
const TRIAL_ELIGIBLE_PATHS = new Set(["/api/process"]);
|
||||
|
||||
function isTrialEligiblePath(reqPath, method) {
|
||||
if (method !== "POST") return false;
|
||||
return TRIAL_ELIGIBLE_PATHS.has(reqPath);
|
||||
}
|
||||
|
||||
function isPublicPath(reqPath) {
|
||||
for (const p of PUBLIC_PATH_PREFIXES) {
|
||||
if (reqPath === p || reqPath.startsWith(p)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// We only gate /api/* paths. Static assets (/, /auth.html, /assets/*)
|
||||
// and the auth-flow routes (/auth/*) are ALWAYS reachable so anonymous
|
||||
// visitors can see the landing page and the sign-in form. The handlers
|
||||
// behind /api/* are where actual access decisions need to happen.
|
||||
function isGatedPath(reqPath) {
|
||||
return reqPath.startsWith("/api/");
|
||||
}
|
||||
|
||||
// Lightweight cookie parser — same dep (`cookie` v1) the rest of the
|
||||
// project uses. Keeps us from pulling in cookie-parser middleware just
|
||||
// for one header.
|
||||
import * as cookie from "cookie";
|
||||
|
||||
function parseCookies(req) {
|
||||
const header = req.headers?.cookie;
|
||||
if (!header) return {};
|
||||
try {
|
||||
return cookie.parse(header);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Factory — returns the middleware closure. `mode` is the RECAP_MODE
|
||||
// env value ("single" | "multi"). Captured at boot so we don't branch
|
||||
// on a hot path on every request.
|
||||
export function buildTenantAuthMiddleware({ mode }) {
|
||||
if (mode !== "multi") {
|
||||
// Single-mode shim: stamp every request with the synthetic owner
|
||||
// userId so user-scoped handlers (history reads, library inserts,
|
||||
// etc.) keep working without per-call branching.
|
||||
return function singleModeAuth(req, _res, next) {
|
||||
req.userId = "owner";
|
||||
req.user = null;
|
||||
req.recapMode = "single";
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Multi-mode: validate session on every request. Cache one prepared
|
||||
// statement per query in module scope (better-sqlite3 idiom — `.prepare`
|
||||
// returns a reusable handle).
|
||||
let sessionLookupStmt = null;
|
||||
let userLookupStmt = null;
|
||||
let sessionTouchStmt = null;
|
||||
|
||||
function stmts() {
|
||||
const db = getDb();
|
||||
if (!sessionLookupStmt) {
|
||||
sessionLookupStmt = db.prepare(
|
||||
"SELECT * FROM sessions WHERE id = ? AND expires_at > ?",
|
||||
);
|
||||
userLookupStmt = db.prepare("SELECT * FROM users WHERE id = ?");
|
||||
sessionTouchStmt = db.prepare(
|
||||
"UPDATE sessions SET last_used_at = ? WHERE id = ?",
|
||||
);
|
||||
}
|
||||
return { sessionLookupStmt, userLookupStmt, sessionTouchStmt };
|
||||
}
|
||||
|
||||
// Touch debounce — every authenticated request would otherwise issue
|
||||
// an UPDATE for last_used_at. We coalesce per-session: at most one
|
||||
// touch per LAST_USED_DEBOUNCE_MS window.
|
||||
const LAST_USED_DEBOUNCE_MS = 60_000;
|
||||
const lastTouchedAt = new Map();
|
||||
|
||||
function maybeTouch(sessionId) {
|
||||
const now = Date.now();
|
||||
const prev = lastTouchedAt.get(sessionId) || 0;
|
||||
if (now - prev < LAST_USED_DEBOUNCE_MS) return;
|
||||
lastTouchedAt.set(sessionId, now);
|
||||
try {
|
||||
stmts().sessionTouchStmt.run(now, sessionId);
|
||||
} catch {
|
||||
// Touch is best-effort; a write contention here shouldn't fail
|
||||
// the request.
|
||||
}
|
||||
}
|
||||
|
||||
return function multiModeAuth(req, res, next) {
|
||||
req.recapMode = "multi";
|
||||
|
||||
// Static assets and the auth-flow pages aren't gated at all — they
|
||||
// need to be reachable for any visitor to see the landing page or
|
||||
// sign-in form. Just pass through without attaching anything.
|
||||
if (!isGatedPath(req.path)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const cookies = parseCookies(req);
|
||||
const sessionId = cookies[SESSION_COOKIE];
|
||||
|
||||
if (!sessionId) {
|
||||
// Look up the trial cookie FIRST (regardless of path) so handlers
|
||||
// like /api/account/whoami can see req.trial even on public paths.
|
||||
// We separate "trial is present" from "trial gates access" —
|
||||
// attachment is unconditional, gating is path-dependent below.
|
||||
let trial = null;
|
||||
const trialCookieId = cookies[TRIAL_COOKIE];
|
||||
if (trialCookieId) {
|
||||
try {
|
||||
trial = lookupTrial(trialCookieId);
|
||||
} catch (err) {
|
||||
console.warn("[tenant-auth] trial lookup failed:", err);
|
||||
}
|
||||
if (trial) {
|
||||
req.trial = trial;
|
||||
}
|
||||
}
|
||||
|
||||
// Public paths (auth flow, health, whoami, etc.) pass through.
|
||||
// Handlers should treat req.userId === undefined as "anonymous"
|
||||
// and inspect req.trial separately to render appropriate UI.
|
||||
if (isPublicPath(req.path)) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
// Second lane: anonymous trial cookie with remaining budget.
|
||||
// Attach a synthetic userId so /api/process and other gated
|
||||
// endpoints can run. If the cookie exists but is exhausted,
|
||||
// fall through to 401 — the UI's "sign up for more" nudge
|
||||
// takes it from here.
|
||||
if (trial && hasTrialBudget(trial)) {
|
||||
req.userId = `anon:${trial.cookie_id}`;
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
// No session, no usable trial. The /api/process handler is the
|
||||
// one place where a fresh trial cookie CAN still be minted on
|
||||
// first POST — let it through with no user attached so it can
|
||||
// call issueIfEligible() and either issue + proceed or 401.
|
||||
if (isTrialEligiblePath(req.path, req.method)) {
|
||||
req.userId = undefined; // signal: "pre-trial, mint if eligible"
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: "auth_required" });
|
||||
}
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = stmts().sessionLookupStmt.get(sessionId, Date.now());
|
||||
} catch (err) {
|
||||
console.warn("[tenant-auth] session lookup failed:", err);
|
||||
if (isPublicPath(req.path)) return next();
|
||||
return res.status(500).json({ error: "auth_lookup_failed" });
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
// Stale or expired cookie. Clear it so the browser stops sending
|
||||
// a dead token on every request.
|
||||
res.clearCookie?.(SESSION_COOKIE);
|
||||
if (isPublicPath(req.path)) return next();
|
||||
return res.status(401).json({ error: "session_expired" });
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = stmts().userLookupStmt.get(session.user_id);
|
||||
} catch (err) {
|
||||
console.warn("[tenant-auth] user lookup failed:", err);
|
||||
if (isPublicPath(req.path)) return next();
|
||||
return res.status(500).json({ error: "user_lookup_failed" });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
// User row was deleted but the session row survived — shouldn't
|
||||
// happen under ON DELETE CASCADE, but defend in depth.
|
||||
res.clearCookie?.(SESSION_COOKIE);
|
||||
if (isPublicPath(req.path)) return next();
|
||||
return res.status(401).json({ error: "user_gone" });
|
||||
}
|
||||
|
||||
req.userId = user.id;
|
||||
req.user = user;
|
||||
req.session = session;
|
||||
maybeTouch(sessionId);
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Convenience: a guard middleware to chain after auth, for endpoints
|
||||
// that MUST have a user (where the single-mode synthetic "owner"
|
||||
// won't do, e.g. /api/account/*). Single-mode falls through because
|
||||
// req.userId === "owner" is truthy.
|
||||
export function requireUser(req, res, next) {
|
||||
if (!req.userId) {
|
||||
return res.status(401).json({ error: "auth_required" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Operator-only guard: passes through in single mode (the operator
|
||||
// IS the only user), in multi mode requires req.user.is_admin === 1.
|
||||
// Used to gate the full settings panel (provider keys, prompts, etc.).
|
||||
export function requireOperator(req, res, next) {
|
||||
if (req.recapMode !== "multi") return next();
|
||||
if (!req.user || !req.user.is_admin) {
|
||||
return res.status(403).json({ error: "operator_only" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// Per-tenant credit ledger — operations on the tenant_credits SQLite
|
||||
// table. Two buckets per user:
|
||||
// purchased_balance — permanent (a la carte purchases + admin grants
|
||||
// + anon-trial carry-over on signup). Never
|
||||
// wiped or refilled.
|
||||
// replenish_balance — refillable (initial signup grant + periodic
|
||||
// anniversary refill to tenant_default_credits).
|
||||
// Leftovers at the end of a period are FORFEIT.
|
||||
//
|
||||
// Spend order: replenish first, then purchased — refillable bucket
|
||||
// is "use it or lose it" so it makes sense to burn first.
|
||||
//
|
||||
// Multi-mode only. Single-mode doesn't use this table at all.
|
||||
|
||||
import { getDb } from "./db.js";
|
||||
import { getConfigSnapshot } from "./config.js";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// addMonthClamped(date) — calendar-month add for monthly replenishment.
|
||||
// Mirrors the relay's same-named helper: Jan 31 → Feb 28/29 (clamped),
|
||||
// Feb 28 → Mar 28 (preserve day-of-month). Returns a Date.
|
||||
function addMonthClamped(date) {
|
||||
const d = new Date(date.getTime());
|
||||
const year = d.getUTCFullYear();
|
||||
const month = d.getUTCMonth();
|
||||
const day = d.getUTCDate();
|
||||
const lastDayOfTargetMonth = new Date(
|
||||
Date.UTC(year, month + 2, 0),
|
||||
).getUTCDate();
|
||||
const targetDay = Math.min(day, lastDayOfTargetMonth);
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
year,
|
||||
month + 1,
|
||||
targetDay,
|
||||
d.getUTCHours(),
|
||||
d.getUTCMinutes(),
|
||||
d.getUTCSeconds(),
|
||||
d.getUTCMilliseconds(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the next anniversary boundary after `last` for the configured
|
||||
// period. Returns null if period is "off" (no replenishment).
|
||||
function nextReplenishAt(lastEpochMs, period) {
|
||||
if (period === "off" || !lastEpochMs) return null;
|
||||
if (period === "daily") return lastEpochMs + DAY_MS;
|
||||
if (period === "weekly") return lastEpochMs + 7 * DAY_MS;
|
||||
if (period === "monthly") {
|
||||
return addMonthClamped(new Date(lastEpochMs)).getTime();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read the current operator config relevant to credits. Cached per-
|
||||
// request by getConfigSnapshot (which polls config.js's snapshot).
|
||||
async function readCreditConfig() {
|
||||
const snap = await getConfigSnapshot();
|
||||
const period =
|
||||
snap?.tenant_credit_replenish_period &&
|
||||
["off", "daily", "weekly", "monthly"].includes(
|
||||
snap.tenant_credit_replenish_period,
|
||||
)
|
||||
? snap.tenant_credit_replenish_period
|
||||
: "off";
|
||||
const defaultCredits = Math.max(
|
||||
0,
|
||||
parseInt(snap?.tenant_default_credits ?? 5, 10) || 0,
|
||||
);
|
||||
return { period, defaultCredits };
|
||||
}
|
||||
|
||||
// Internal: ensure a tenant_credits row exists for this user. New users
|
||||
// (just signed up but no row yet — shouldn't happen post-auth-routes-fix
|
||||
// but defensive) get a row with replenish_balance = current default.
|
||||
function ensureRow(userId, defaultCredits) {
|
||||
const db = getDb();
|
||||
const existing = db
|
||||
.prepare("SELECT * FROM tenant_credits WHERE user_id = ?")
|
||||
.get(userId);
|
||||
if (existing) return existing;
|
||||
const now = Date.now();
|
||||
db.prepare(
|
||||
`INSERT INTO tenant_credits
|
||||
(user_id, purchased_balance, replenish_balance, last_replenish_at,
|
||||
lifetime_granted, lifetime_consumed)
|
||||
VALUES (?, 0, ?, ?, ?, 0)`,
|
||||
).run(userId, defaultCredits, now, defaultCredits);
|
||||
return db
|
||||
.prepare("SELECT * FROM tenant_credits WHERE user_id = ?")
|
||||
.get(userId);
|
||||
}
|
||||
|
||||
// Apply periodic refill if due. Returns the (possibly updated) row.
|
||||
// Anniversary semantics: if last_replenish_at + period_ms <= now, the
|
||||
// replenish bucket is RESET to defaultCredits (any leftover is
|
||||
// forfeit), and last_replenish_at is advanced. Idempotent if called
|
||||
// multiple times in the same period (no-op).
|
||||
//
|
||||
// "Multi-period catch-up" rule: if a user has been idle for several
|
||||
// periods, only ONE refill is applied (we don't stack refills from
|
||||
// missed periods). They effectively lost the credits for the missed
|
||||
// days — same as a per-day allowance in any other SaaS.
|
||||
function maybeReplenish(row, period, defaultCredits) {
|
||||
if (period === "off") return row;
|
||||
const due = nextReplenishAt(row.last_replenish_at, period);
|
||||
if (due === null) return row;
|
||||
const now = Date.now();
|
||||
if (now < due) return row;
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`UPDATE tenant_credits
|
||||
SET replenish_balance = ?, last_replenish_at = ?
|
||||
WHERE user_id = ?`,
|
||||
).run(defaultCredits, now, row.user_id);
|
||||
return {
|
||||
...row,
|
||||
replenish_balance: defaultCredits,
|
||||
last_replenish_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
// getOrInit(userId) — fetch the tenant_credits row, lazily refilling if
|
||||
// the period boundary has passed. Returns the canonical shape for
|
||||
// callers that just want to display + compute totals.
|
||||
export async function getOrInit(userId) {
|
||||
if (!userId) return null;
|
||||
const { period, defaultCredits } = await readCreditConfig();
|
||||
let row = ensureRow(userId, defaultCredits);
|
||||
row = maybeReplenish(row, period, defaultCredits);
|
||||
return {
|
||||
user_id: row.user_id,
|
||||
purchased: row.purchased_balance,
|
||||
replenish: row.replenish_balance,
|
||||
total: row.purchased_balance + row.replenish_balance,
|
||||
last_replenish_at: row.last_replenish_at,
|
||||
lifetime_granted: row.lifetime_granted,
|
||||
lifetime_consumed: row.lifetime_consumed,
|
||||
period, // surfaces config in returned shape for UI hints
|
||||
};
|
||||
}
|
||||
|
||||
// gateAndDebit(userId) — atomic: refill if due, check total > 0, debit
|
||||
// one credit (replenish first, then purchased). Returns
|
||||
// { ok: true, total, source: "replenish"|"purchased" } on success,
|
||||
// { ok: false, reason: "no_credits", total: 0 } if nothing available.
|
||||
export async function gateAndDebit(userId) {
|
||||
if (!userId) return { ok: false, reason: "no_user_id" };
|
||||
const state = await getOrInit(userId);
|
||||
if (!state) return { ok: false, reason: "no_user_id" };
|
||||
if (state.total <= 0) {
|
||||
return { ok: false, reason: "no_credits", total: 0 };
|
||||
}
|
||||
const db = getDb();
|
||||
const tx = db.transaction(() => {
|
||||
if (state.replenish > 0) {
|
||||
db.prepare(
|
||||
`UPDATE tenant_credits
|
||||
SET replenish_balance = replenish_balance - 1,
|
||||
lifetime_consumed = lifetime_consumed + 1
|
||||
WHERE user_id = ?`,
|
||||
).run(userId);
|
||||
return "replenish";
|
||||
}
|
||||
db.prepare(
|
||||
`UPDATE tenant_credits
|
||||
SET purchased_balance = purchased_balance - 1,
|
||||
lifetime_consumed = lifetime_consumed + 1
|
||||
WHERE user_id = ?`,
|
||||
).run(userId);
|
||||
return "purchased";
|
||||
});
|
||||
const source = tx();
|
||||
// Re-read for the new total. Cheap — same row, no replenish-check
|
||||
// needed since we just touched it.
|
||||
const fresh = db
|
||||
.prepare(
|
||||
"SELECT purchased_balance, replenish_balance FROM tenant_credits WHERE user_id = ?",
|
||||
)
|
||||
.get(userId);
|
||||
return {
|
||||
ok: true,
|
||||
total: (fresh.purchased_balance || 0) + (fresh.replenish_balance || 0),
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
// addPurchased(userId, amount) — increment the permanent bucket. Used
|
||||
// by admin grants AND a la carte purchase apply AND anon-trial
|
||||
// carry-over on signup. Increments lifetime_granted too.
|
||||
//
|
||||
// We don't replenish-check here — adding to the permanent bucket
|
||||
// shouldn't trigger a refill side-effect. The refill happens lazily
|
||||
// on the next getOrInit() call.
|
||||
export function addPurchased(userId, amount) {
|
||||
if (!userId || !Number.isFinite(amount) || amount <= 0) return null;
|
||||
const db = getDb();
|
||||
const existing = db
|
||||
.prepare("SELECT user_id FROM tenant_credits WHERE user_id = ?")
|
||||
.get(userId);
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE tenant_credits
|
||||
SET purchased_balance = purchased_balance + ?,
|
||||
lifetime_granted = lifetime_granted + ?
|
||||
WHERE user_id = ?`,
|
||||
).run(amount, amount, userId);
|
||||
} else {
|
||||
// Row didn't exist — initialize WITHOUT a replenishable seed
|
||||
// (this user hasn't been through the signup flow on this Recap;
|
||||
// probably an admin granting credits before they sign in).
|
||||
const now = Date.now();
|
||||
db.prepare(
|
||||
`INSERT INTO tenant_credits
|
||||
(user_id, purchased_balance, replenish_balance, last_replenish_at,
|
||||
lifetime_granted, lifetime_consumed)
|
||||
VALUES (?, ?, 0, ?, ?, 0)`,
|
||||
).run(userId, amount, now, amount);
|
||||
}
|
||||
return db
|
||||
.prepare("SELECT * FROM tenant_credits WHERE user_id = ?")
|
||||
.get(userId);
|
||||
}
|
||||
|
||||
// seedSignup(userId, amount?) — initialize a tenant_credits row at
|
||||
// signup. Seeds replenish_balance with the configured default
|
||||
// (overridable for testing), sets last_replenish_at = now so the
|
||||
// first refill boundary is computed correctly.
|
||||
export async function seedSignup(userId, amountOverride) {
|
||||
if (!userId) return null;
|
||||
const { defaultCredits } = await readCreditConfig();
|
||||
const amount =
|
||||
typeof amountOverride === "number" && amountOverride >= 0
|
||||
? amountOverride
|
||||
: defaultCredits;
|
||||
const db = getDb();
|
||||
const existing = db
|
||||
.prepare("SELECT user_id FROM tenant_credits WHERE user_id = ?")
|
||||
.get(userId);
|
||||
if (existing) return existing; // don't re-seed
|
||||
const now = Date.now();
|
||||
db.prepare(
|
||||
`INSERT INTO tenant_credits
|
||||
(user_id, purchased_balance, replenish_balance, last_replenish_at,
|
||||
lifetime_granted, lifetime_consumed)
|
||||
VALUES (?, 0, ?, ?, ?, 0)`,
|
||||
).run(userId, amount, now, amount);
|
||||
return db
|
||||
.prepare("SELECT * FROM tenant_credits WHERE user_id = ?")
|
||||
.get(userId);
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import { PRICING, calcCost, buildAnalysisPrompt } from "../gemini-helpers.js";
|
||||
|
||||
describe("PRICING table", () => {
|
||||
test("includes all current production model slugs", () => {
|
||||
assert.ok(PRICING["gemini-3-flash-preview"]);
|
||||
assert.ok(PRICING["gemini-3-pro-preview"]);
|
||||
assert.ok(PRICING["gemini-3.1-pro-preview"]);
|
||||
assert.ok(PRICING["gemini-2.5-pro"]);
|
||||
assert.ok(PRICING["gemini-3-flash-preview"]);
|
||||
assert.ok(PRICING["gemini-2.5-flash"]);
|
||||
assert.ok(PRICING["gemini-3.1-flash-lite"]);
|
||||
});
|
||||
|
||||
test("has a 'default' fallback row", () => {
|
||||
@@ -66,7 +67,7 @@ describe("calcCost", () => {
|
||||
});
|
||||
|
||||
test("formats >$0.01 totals as $X.XXXX", () => {
|
||||
const cost = calcCost("gemini-3-pro-preview", {
|
||||
const cost = calcCost("gemini-3.1-pro-preview", {
|
||||
promptTokenCount: 1_000_000,
|
||||
candidatesTokenCount: 0,
|
||||
thoughtsTokenCount: 0,
|
||||
|
||||
+21
-11
@@ -43,8 +43,14 @@ describe("initHistory + getHistoryDir", () => {
|
||||
});
|
||||
|
||||
describe("saveToHistory", () => {
|
||||
// saveToHistory is scope-aware: it takes a `scope` first and writes under
|
||||
// history/<scope>/. Tests use the "owner" scope (single-mode / operator).
|
||||
const SCOPE = "owner";
|
||||
const scopeFile = (id) => path.join(historyDir, SCOPE, `${id}.json`);
|
||||
|
||||
test("returns an id and writes a file with the expected shape", async () => {
|
||||
const id = await history.saveToHistory(
|
||||
SCOPE,
|
||||
"videoId123",
|
||||
"https://youtu.be/videoId123",
|
||||
"My title",
|
||||
@@ -56,7 +62,7 @@ describe("saveToHistory", () => {
|
||||
);
|
||||
assert.match(id, /^\d+-videoId123$/);
|
||||
|
||||
const raw = await fs.readFile(path.join(historyDir, `${id}.json`), "utf-8");
|
||||
const raw = await fs.readFile(scopeFile(id), "utf-8");
|
||||
const record = JSON.parse(raw);
|
||||
assert.equal(record.id, id);
|
||||
assert.equal(record.videoId, "videoId123");
|
||||
@@ -74,6 +80,7 @@ describe("saveToHistory", () => {
|
||||
|
||||
test("falls back to 'Untitled' when title is empty", async () => {
|
||||
const id = await history.saveToHistory(
|
||||
SCOPE,
|
||||
"noTitleX",
|
||||
"url",
|
||||
"",
|
||||
@@ -83,20 +90,20 @@ describe("saveToHistory", () => {
|
||||
"",
|
||||
"youtube"
|
||||
);
|
||||
const raw = await fs.readFile(path.join(historyDir, `${id}.json`), "utf-8");
|
||||
const raw = await fs.readFile(scopeFile(id), "utf-8");
|
||||
const record = JSON.parse(raw);
|
||||
assert.equal(record.title, "Untitled");
|
||||
});
|
||||
|
||||
test("defaults type to 'youtube' when not specified", async () => {
|
||||
const id = await history.saveToHistory("vid", "url", "t", [], [], [], "", null);
|
||||
const raw = await fs.readFile(path.join(historyDir, `${id}.json`), "utf-8");
|
||||
const id = await history.saveToHistory(SCOPE, "vid", "url", "t", [], [], [], "", null);
|
||||
const raw = await fs.readFile(scopeFile(id), "utf-8");
|
||||
assert.equal(JSON.parse(raw).type, "youtube");
|
||||
});
|
||||
|
||||
test("encodes long podcast guids into a base64-truncated id suffix", async () => {
|
||||
const longGuid = "https://example.com/podcasts/feed.xml#episode-uuid-very-long-string";
|
||||
const id = await history.saveToHistory(longGuid, longGuid, "ep", [], [], [], "", "podcast");
|
||||
const id = await history.saveToHistory(SCOPE, longGuid, longGuid, "ep", [], [], [], "", "podcast");
|
||||
// suffix should be 16 base64 chars, not the raw URL
|
||||
assert.ok(!id.includes("https"));
|
||||
assert.match(id, /^\d+-[A-Za-z0-9_-]{16}$/);
|
||||
@@ -104,9 +111,10 @@ describe("saveToHistory", () => {
|
||||
});
|
||||
|
||||
describe("loadMeta + saveMeta", () => {
|
||||
// loadMeta/saveMeta are scope-aware (history/<scope>/_meta.json).
|
||||
test("loadMeta returns default empty shape when file missing", async () => {
|
||||
// Use a fresh sub-history to ensure no prior _meta.json
|
||||
const meta = await history.loadMeta();
|
||||
// A scope that has never been written → default shape.
|
||||
const meta = await history.loadMeta("never-written-scope");
|
||||
assert.ok(Array.isArray(meta.folders));
|
||||
assert.ok(Array.isArray(meta.uncategorized));
|
||||
});
|
||||
@@ -116,14 +124,16 @@ describe("loadMeta + saveMeta", () => {
|
||||
folders: [{ id: "f1", name: "Bitcoin podcasts", collapsed: false, items: ["s1", "s2"] }],
|
||||
uncategorized: ["s3"],
|
||||
};
|
||||
await history.saveMeta(original);
|
||||
const loaded = await history.loadMeta();
|
||||
await history.saveMeta("owner", original);
|
||||
const loaded = await history.loadMeta("owner");
|
||||
assert.deepEqual(loaded, original);
|
||||
});
|
||||
|
||||
test("loadMeta returns default when _meta.json is corrupt", async () => {
|
||||
await fs.writeFile(path.join(historyDir, "_meta.json"), "{ this is not json");
|
||||
const loaded = await history.loadMeta();
|
||||
const dir = path.join(historyDir, "corrupt-scope");
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "_meta.json"), "{ this is not json");
|
||||
const loaded = await history.loadMeta("corrupt-scope");
|
||||
assert.deepEqual(loaded.folders, []);
|
||||
assert.deepEqual(loaded.uncategorized, []);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Tests for the ephemeral sessions the subscription background processor
|
||||
// mints to run /api/process AS each item's owner (per-tenant subscriptions,
|
||||
// step 4). The mechanism reuses the real sessions table, so verifying the
|
||||
// row it writes is valid + non-expired (and gets cleaned up) is enough to
|
||||
// trust the existing cookie → tenant-auth → req.user chain.
|
||||
|
||||
import { test, describe, before, after } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { initDb, getDb, closeDb } from "../db.js";
|
||||
import {
|
||||
mintInternalSession,
|
||||
deleteInternalSession,
|
||||
adminUserId,
|
||||
} from "../auth-routes.js";
|
||||
|
||||
let dataDir;
|
||||
|
||||
function makeUser({ id, email, isAdmin = 0 }) {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO users (id, email, created_at, synthetic_install_id, is_admin)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(id, email, Date.now(), `inst-${id}`, isAdmin);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
dataDir = mkdtempSync(path.join(tmpdir(), "recap-sess-"));
|
||||
await initDb({ dataDir });
|
||||
});
|
||||
|
||||
after(() => {
|
||||
closeDb();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("mintInternalSession / deleteInternalSession", () => {
|
||||
test("creates a valid, non-expired session row for the user", () => {
|
||||
makeUser({ id: "u-tenant", email: "tenant@example.com" });
|
||||
const token = mintInternalSession("u-tenant");
|
||||
assert.ok(typeof token === "string" && token.length > 20);
|
||||
|
||||
// Looks up exactly like tenant-auth does: by id, must be unexpired.
|
||||
const row = getDb()
|
||||
.prepare("SELECT * FROM sessions WHERE id = ? AND expires_at > ?")
|
||||
.get(token, Date.now());
|
||||
assert.ok(row, "session row exists and is not expired");
|
||||
assert.equal(row.user_id, "u-tenant");
|
||||
assert.ok(row.expires_at > Date.now(), "expires in the future");
|
||||
});
|
||||
|
||||
test("deleteInternalSession removes the row (no lingering identity)", () => {
|
||||
makeUser({ id: "u-temp", email: "temp@example.com" });
|
||||
const token = mintInternalSession("u-temp");
|
||||
assert.ok(getDb().prepare("SELECT 1 FROM sessions WHERE id = ?").get(token));
|
||||
deleteInternalSession(token);
|
||||
assert.equal(
|
||||
getDb().prepare("SELECT 1 FROM sessions WHERE id = ?").get(token),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("deleteInternalSession tolerates null / unknown tokens", () => {
|
||||
// Should not throw.
|
||||
deleteInternalSession(null);
|
||||
deleteInternalSession("does-not-exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adminUserId", () => {
|
||||
test("returns the operator (is_admin = 1) user id", () => {
|
||||
makeUser({ id: "u-admin", email: "admin@example.com", isAdmin: 1 });
|
||||
assert.equal(adminUserId(), "u-admin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// Pure-logic tests for the expiry-reminder cadence: which reminder kind
|
||||
// (if any) applies to a relay subscription row right now. The send/dedup
|
||||
// path hits SQLite + SMTP and isn't unit-tested here; this nails the
|
||||
// decision that drives it.
|
||||
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { reminderKindFor } from "../subscription-reminders.js";
|
||||
|
||||
const sub = (days_left, expired = false) => ({
|
||||
tier: "pro",
|
||||
expires_at: "2026-07-01T00:00:00.000Z",
|
||||
expired,
|
||||
days_left,
|
||||
});
|
||||
|
||||
describe("reminderKindFor", () => {
|
||||
test("upcoming: smallest crossed threshold wins", () => {
|
||||
assert.equal(reminderKindFor(sub(8)), null); // beyond 7d
|
||||
assert.equal(reminderKindFor(sub(7)), "upcoming_7d");
|
||||
assert.equal(reminderKindFor(sub(5)), "upcoming_7d");
|
||||
assert.equal(reminderKindFor(sub(2)), "upcoming_7d");
|
||||
assert.equal(reminderKindFor(sub(1)), "upcoming_1d");
|
||||
assert.equal(reminderKindFor(sub(0)), "upcoming_1d"); // expires today, not yet lapsed
|
||||
});
|
||||
|
||||
test("lapsed: only within the lapsed window", () => {
|
||||
assert.equal(reminderKindFor(sub(0, true)), "lapsed");
|
||||
assert.equal(reminderKindFor(sub(-1, true)), "lapsed");
|
||||
assert.equal(reminderKindFor(sub(-3, true)), "lapsed");
|
||||
assert.equal(reminderKindFor(sub(-4, true)), null); // aged out (window=3)
|
||||
});
|
||||
|
||||
test("null-safe / malformed input", () => {
|
||||
assert.equal(reminderKindFor(null), null);
|
||||
assert.equal(reminderKindFor({}), null);
|
||||
assert.equal(reminderKindFor({ expired: false }), null); // no days_left
|
||||
assert.equal(reminderKindFor({ days_left: "soon" }), null);
|
||||
});
|
||||
|
||||
test("respects custom thresholds", () => {
|
||||
const upcoming = [
|
||||
{ days: 14, kind: "upcoming_14d" },
|
||||
{ days: 3, kind: "upcoming_3d" },
|
||||
];
|
||||
assert.equal(reminderKindFor(sub(14), { upcoming }), "upcoming_14d");
|
||||
assert.equal(reminderKindFor(sub(3), { upcoming }), "upcoming_3d");
|
||||
assert.equal(reminderKindFor(sub(15), { upcoming }), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import { test, describe } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { initHistory, getScopeHistoryDir, getHistoryDir } from "../history.js";
|
||||
import {
|
||||
getProcessedVideoIds,
|
||||
isKnownVideo,
|
||||
loadSubscriptions,
|
||||
saveSubscriptions,
|
||||
loadSkipList,
|
||||
addToSkipList,
|
||||
loadSeenList,
|
||||
addToSeenList,
|
||||
loadAutoQueue,
|
||||
saveAutoQueue,
|
||||
mutateAutoQueue,
|
||||
listSubscriptionScopes,
|
||||
migrateGlobalSubscriptionsToOwner,
|
||||
} from "../subscriptions.js";
|
||||
|
||||
// ── isKnownVideo (pure dedup predicate) ──────────────────────────────────
|
||||
describe("isKnownVideo", () => {
|
||||
const sets = {
|
||||
processedIds: new Set(["P1"]),
|
||||
queuedIds: new Set(["Q1"]),
|
||||
skippedIds: new Set(["S1"]),
|
||||
seenIds: new Set(["N1"]),
|
||||
};
|
||||
|
||||
test("true when the id is in the library (processed)", () => {
|
||||
assert.equal(isKnownVideo("P1", sets), true);
|
||||
});
|
||||
test("true when the id is already queued", () => {
|
||||
assert.equal(isKnownVideo("Q1", sets), true);
|
||||
});
|
||||
test("true when the id was declined (skip list)", () => {
|
||||
assert.equal(isKnownVideo("S1", sets), true);
|
||||
});
|
||||
test("true when the id was offered before (seen list)", () => {
|
||||
assert.equal(isKnownVideo("N1", sets), true);
|
||||
});
|
||||
test("false for a genuinely new id", () => {
|
||||
assert.equal(isKnownVideo("NEW", sets), false);
|
||||
});
|
||||
test("returns a real boolean, not a Set/undefined", () => {
|
||||
assert.equal(typeof isKnownVideo("NEW", sets), "boolean");
|
||||
assert.equal(typeof isKnownVideo("P1", sets), "boolean");
|
||||
});
|
||||
test("tolerates missing/partial set bags", () => {
|
||||
assert.equal(isKnownVideo("X", {}), false);
|
||||
assert.equal(isKnownVideo("X", undefined), false);
|
||||
assert.equal(isKnownVideo("X", { processedIds: new Set(["X"]) }), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getProcessedVideoIds (scope-aware library scan) ──────────────────────
|
||||
// This is the function whose wrong-directory bug caused the auto-queue to
|
||||
// re-offer already-summarized videos: it must scan history/<scope>/, NOT
|
||||
// the top-level history dir, and must not leak ids across scopes.
|
||||
describe("getProcessedVideoIds", () => {
|
||||
function freshDataDir() {
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), "recap-subs-"));
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
test("scans the scope's library dir and collects videoIds", async () => {
|
||||
const dataDir = freshDataDir();
|
||||
await initHistory({ dataDir, mode: "multi" });
|
||||
const ownerDir = getScopeHistoryDir("owner");
|
||||
mkdirSync(ownerDir, { recursive: true });
|
||||
writeFileSync(path.join(ownerDir, "a.json"), JSON.stringify({ videoId: "VID_A" }));
|
||||
writeFileSync(path.join(ownerDir, "b.json"), JSON.stringify({ videoId: "VID_B" }));
|
||||
|
||||
const ids = await getProcessedVideoIds("owner");
|
||||
assert.equal(ids.size, 2);
|
||||
assert.ok(ids.has("VID_A"));
|
||||
assert.ok(ids.has("VID_B"));
|
||||
});
|
||||
|
||||
test("does NOT leak ids across scopes (per-tenant isolation)", async () => {
|
||||
const dataDir = freshDataDir();
|
||||
await initHistory({ dataDir, mode: "multi" });
|
||||
const ownerDir = getScopeHistoryDir("owner");
|
||||
const tenantDir = getScopeHistoryDir("tenant123");
|
||||
mkdirSync(ownerDir, { recursive: true });
|
||||
mkdirSync(tenantDir, { recursive: true });
|
||||
writeFileSync(path.join(ownerDir, "a.json"), JSON.stringify({ videoId: "OWNER_VID" }));
|
||||
writeFileSync(path.join(tenantDir, "c.json"), JSON.stringify({ videoId: "TENANT_VID" }));
|
||||
|
||||
const ownerIds = await getProcessedVideoIds("owner");
|
||||
assert.ok(ownerIds.has("OWNER_VID"));
|
||||
assert.equal(ownerIds.has("TENANT_VID"), false);
|
||||
});
|
||||
|
||||
test("ignores _meta.json and non-JSON / malformed files", async () => {
|
||||
const dataDir = freshDataDir();
|
||||
await initHistory({ dataDir, mode: "multi" });
|
||||
const ownerDir = getScopeHistoryDir("owner");
|
||||
mkdirSync(ownerDir, { recursive: true });
|
||||
writeFileSync(path.join(ownerDir, "good.json"), JSON.stringify({ videoId: "GOOD" }));
|
||||
writeFileSync(path.join(ownerDir, "_meta.json"), JSON.stringify({ folders: [], uncategorized: [] }));
|
||||
writeFileSync(path.join(ownerDir, "notes.txt"), "videoId: NOPE");
|
||||
writeFileSync(path.join(ownerDir, "broken.json"), "{ not valid json");
|
||||
writeFileSync(path.join(ownerDir, "nofield.json"), JSON.stringify({ title: "no videoId here" }));
|
||||
|
||||
const ids = await getProcessedVideoIds("owner");
|
||||
assert.deepEqual([...ids], ["GOOD"]);
|
||||
});
|
||||
|
||||
test("returns an empty set for a scope with no library yet", async () => {
|
||||
const dataDir = freshDataDir();
|
||||
await initHistory({ dataDir, mode: "multi" });
|
||||
const ids = await getProcessedVideoIds("never-summarized");
|
||||
assert.equal(ids.size, 0);
|
||||
});
|
||||
|
||||
test("defaults to the owner scope when none is passed", async () => {
|
||||
const dataDir = freshDataDir();
|
||||
await initHistory({ dataDir, mode: "single" });
|
||||
const ownerDir = getScopeHistoryDir("owner");
|
||||
mkdirSync(ownerDir, { recursive: true });
|
||||
writeFileSync(path.join(ownerDir, "a.json"), JSON.stringify({ videoId: "DEFAULT_OWNER" }));
|
||||
|
||||
const ids = await getProcessedVideoIds(); // no arg → "owner"
|
||||
assert.ok(ids.has("DEFAULT_OWNER"));
|
||||
});
|
||||
|
||||
// Regression: after the 0.2.147 migration the subscription sidecar files
|
||||
// live inside the scope dir. They must NOT be treated as session records
|
||||
// (that produced the phantom "Invalid Date · undefined topics" library
|
||||
// entry). Verify the ROOT_SIDECARS filter skips them even if one happens
|
||||
// to carry a top-level videoId.
|
||||
test("skips subscription sidecar files when scanning the library", async () => {
|
||||
const dataDir = freshDataDir();
|
||||
await initHistory({ dataDir, mode: "multi" });
|
||||
const ownerDir = getScopeHistoryDir("owner");
|
||||
mkdirSync(ownerDir, { recursive: true });
|
||||
writeFileSync(path.join(ownerDir, "real.json"), JSON.stringify({ videoId: "REAL" }));
|
||||
for (const f of ["subscriptions.json", "auto-queue.json", "skip-list.json", "seen-list.json"]) {
|
||||
// Even with a (bogus) top-level videoId, a sidecar must be ignored.
|
||||
writeFileSync(path.join(ownerDir, f), JSON.stringify({ videoId: "SIDECAR_" + f, items: [] }));
|
||||
}
|
||||
const ids = await getProcessedVideoIds("owner");
|
||||
assert.deepEqual([...ids], ["REAL"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Per-scope storage (subscriptions / skip / seen / auto-queue) ─────────
|
||||
describe("per-scope storage", () => {
|
||||
function fresh() {
|
||||
return mkdtempSync(path.join(tmpdir(), "recap-store-"));
|
||||
}
|
||||
|
||||
test("subscriptions round-trip and stay isolated per scope", async () => {
|
||||
await initHistory({ dataDir: fresh(), mode: "multi" });
|
||||
assert.deepEqual(await loadSubscriptions("owner"), []); // empty default
|
||||
await saveSubscriptions("owner", [{ id: "s1", url: "u1" }]);
|
||||
await saveSubscriptions("tenantA", [{ id: "s2", url: "u2" }]);
|
||||
assert.equal((await loadSubscriptions("owner")).length, 1);
|
||||
assert.equal((await loadSubscriptions("owner"))[0].id, "s1");
|
||||
assert.equal((await loadSubscriptions("tenantA"))[0].id, "s2");
|
||||
});
|
||||
|
||||
test("skip + seen lists are per-scope Sets", async () => {
|
||||
await initHistory({ dataDir: fresh(), mode: "multi" });
|
||||
await addToSkipList("owner", "VID1");
|
||||
await addToSkipList("owner", "VID2");
|
||||
await addToSeenList("owner", ["S1", "S2"]);
|
||||
const skip = await loadSkipList("owner");
|
||||
const seen = await loadSeenList("owner");
|
||||
assert.ok(skip.has("VID1") && skip.has("VID2"));
|
||||
assert.ok(seen.has("S1") && seen.has("S2"));
|
||||
assert.equal((await loadSkipList("tenantA")).size, 0); // isolated
|
||||
});
|
||||
|
||||
test("auto-queue save/load round-trips", async () => {
|
||||
await initHistory({ dataDir: fresh(), mode: "multi" });
|
||||
await saveAutoQueue("owner", [{ id: "a", status: "pending" }]);
|
||||
const q = await loadAutoQueue("owner");
|
||||
assert.equal(q.length, 1);
|
||||
assert.equal(q[0].status, "pending");
|
||||
});
|
||||
|
||||
test("mutateAutoQueue serializes concurrent read-modify-writes (no lost updates)", async () => {
|
||||
await initHistory({ dataDir: fresh(), mode: "multi" });
|
||||
await saveAutoQueue("owner", []);
|
||||
// Fire 20 concurrent appends. Under a naive load→mutate→save these would
|
||||
// clobber each other; mutateAutoQueue must serialize them.
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, (_, i) =>
|
||||
mutateAutoQueue("owner", (items) => {
|
||||
items.push({ id: `item-${i}` });
|
||||
}),
|
||||
),
|
||||
);
|
||||
const q = await loadAutoQueue("owner");
|
||||
assert.equal(q.length, 20, "all 20 concurrent appends survived");
|
||||
assert.equal(new Set(q.map((x) => x.id)).size, 20, "no duplicates/drops");
|
||||
});
|
||||
|
||||
test("mutateAutoQueue can replace the array (filter/remove)", async () => {
|
||||
await initHistory({ dataDir: fresh(), mode: "multi" });
|
||||
await saveAutoQueue("owner", [{ id: "keep" }, { id: "drop" }]);
|
||||
await mutateAutoQueue("owner", (items) => items.filter((x) => x.id !== "drop"));
|
||||
const q = await loadAutoQueue("owner");
|
||||
assert.deepEqual(q.map((x) => x.id), ["keep"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Auto-queue endpoint mutation patterns ────────────────────────────────
|
||||
// The /api/auto-queue/* handlers run these exact callbacks through
|
||||
// mutateAutoQueue. Verify the logic directly (the HTTP layer just adds the
|
||||
// license gate + scope resolution, covered elsewhere).
|
||||
describe("auto-queue endpoint mutation logic", () => {
|
||||
async function seed(items) {
|
||||
await initHistory({ dataDir: mkdtempSync(path.join(tmpdir(), "recap-ep-")), mode: "multi" });
|
||||
await saveAutoQueue("owner", items);
|
||||
}
|
||||
|
||||
test("approve: pending → approved; rejects non-pending", async () => {
|
||||
await seed([{ id: "x", status: "pending" }, { id: "y", status: "completed" }]);
|
||||
// approve x
|
||||
let item = null, badStatus = null;
|
||||
await mutateAutoQueue("owner", (items) => {
|
||||
const it = items.find((q) => q.id === "x");
|
||||
if (!it) return;
|
||||
if (it.status !== "pending") { badStatus = it.status; return; }
|
||||
it.status = "approved";
|
||||
item = { ...it };
|
||||
});
|
||||
assert.equal(badStatus, null);
|
||||
assert.equal(item.status, "approved");
|
||||
assert.equal((await loadAutoQueue("owner")).find((q) => q.id === "x").status, "approved");
|
||||
// approving y (completed) is rejected
|
||||
badStatus = null;
|
||||
await mutateAutoQueue("owner", (items) => {
|
||||
const it = items.find((q) => q.id === "y");
|
||||
if (it && it.status !== "pending") badStatus = it.status;
|
||||
});
|
||||
assert.equal(badStatus, "completed");
|
||||
});
|
||||
|
||||
test("skip: captures videoId then removes the item", async () => {
|
||||
await seed([{ id: "x", videoId: "VID_X", status: "pending" }, { id: "z", status: "pending" }]);
|
||||
let videoId = null;
|
||||
await mutateAutoQueue("owner", (items) => {
|
||||
const it = items.find((q) => q.id === "x");
|
||||
if (it && it.videoId) videoId = it.videoId;
|
||||
return items.filter((q) => q.id !== "x");
|
||||
});
|
||||
assert.equal(videoId, "VID_X");
|
||||
const q = await loadAutoQueue("owner");
|
||||
assert.deepEqual(q.map((i) => i.id), ["z"]);
|
||||
});
|
||||
|
||||
test("clear-finished: drops completed + failed, keeps active", async () => {
|
||||
await seed([
|
||||
{ id: "a", status: "pending" },
|
||||
{ id: "b", status: "completed" },
|
||||
{ id: "c", status: "failed" },
|
||||
{ id: "d", status: "approved" },
|
||||
]);
|
||||
let removed = 0;
|
||||
await mutateAutoQueue("owner", (items) => {
|
||||
const before = items.length;
|
||||
const kept = items.filter((q) => !["completed", "failed"].includes(q.status));
|
||||
removed = before - kept.length;
|
||||
return kept;
|
||||
});
|
||||
assert.equal(removed, 2);
|
||||
assert.deepEqual((await loadAutoQueue("owner")).map((i) => i.id).sort(), ["a", "d"]);
|
||||
});
|
||||
|
||||
test("delete-by-subscription: removes that sub's items only", async () => {
|
||||
await seed([
|
||||
{ id: "a", subscriptionId: "sub1", status: "pending" },
|
||||
{ id: "b", subscriptionId: "sub2", status: "pending" },
|
||||
{ id: "c", subscriptionId: "sub1", status: "approved" },
|
||||
]);
|
||||
await mutateAutoQueue("owner", (items) => items.filter((q) => q.subscriptionId !== "sub1"));
|
||||
assert.deepEqual((await loadAutoQueue("owner")).map((i) => i.id), ["b"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scope enumeration ────────────────────────────────────────────────────
|
||||
describe("listSubscriptionScopes", () => {
|
||||
test("always includes owner; adds scopes with non-empty subscriptions", async () => {
|
||||
await initHistory({ dataDir: mkdtempSync(path.join(tmpdir(), "recap-enum-")), mode: "multi" });
|
||||
await saveSubscriptions("tenantA", [{ id: "s", url: "u" }]);
|
||||
await saveSubscriptions("tenantB", []); // empty → excluded
|
||||
const scopes = await listSubscriptionScopes();
|
||||
assert.ok(scopes.includes("owner"));
|
||||
assert.ok(scopes.includes("tenantA"));
|
||||
assert.ok(!scopes.includes("tenantB"));
|
||||
});
|
||||
|
||||
test("returns just owner when nobody has subscriptions", async () => {
|
||||
await initHistory({ dataDir: mkdtempSync(path.join(tmpdir(), "recap-enum2-")), mode: "multi" });
|
||||
const scopes = await listSubscriptionScopes();
|
||||
assert.deepEqual(scopes, ["owner"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Migration: history-root globals → owner scope ────────────────────────
|
||||
describe("migrateGlobalSubscriptionsToOwner", () => {
|
||||
test("moves root files into owner/, idempotently, without clobbering", async () => {
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), "recap-mig-"));
|
||||
await initHistory({ dataDir, mode: "multi" });
|
||||
const root = getHistoryDir();
|
||||
mkdirSync(root, { recursive: true });
|
||||
// Legacy global files at the history root.
|
||||
writeFileSync(path.join(root, "subscriptions.json"), JSON.stringify({ subscriptions: [{ id: "s1" }] }));
|
||||
writeFileSync(path.join(root, "auto-queue.json"), JSON.stringify({ items: [{ id: "q1" }] }));
|
||||
|
||||
const moved = await migrateGlobalSubscriptionsToOwner();
|
||||
assert.equal(moved, 2);
|
||||
// Now readable via the owner scope.
|
||||
assert.equal((await loadSubscriptions("owner"))[0].id, "s1");
|
||||
assert.equal((await loadAutoQueue("owner"))[0].id, "q1");
|
||||
// Source files gone.
|
||||
assert.equal(existsSync(path.join(root, "subscriptions.json")), false);
|
||||
// Re-running is a no-op (nothing left to move).
|
||||
assert.equal(await migrateGlobalSubscriptionsToOwner(), 0);
|
||||
});
|
||||
|
||||
test("does not clobber an existing owner-scope file", async () => {
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), "recap-mig2-"));
|
||||
await initHistory({ dataDir, mode: "multi" });
|
||||
const root = getHistoryDir();
|
||||
const ownerDir = getScopeHistoryDir("owner");
|
||||
mkdirSync(ownerDir, { recursive: true });
|
||||
// Owner already has subscriptions; a stale root file must NOT overwrite.
|
||||
writeFileSync(path.join(ownerDir, "subscriptions.json"), JSON.stringify({ subscriptions: [{ id: "KEEP" }] }));
|
||||
writeFileSync(path.join(root, "subscriptions.json"), JSON.stringify({ subscriptions: [{ id: "STALE" }] }));
|
||||
|
||||
await migrateGlobalSubscriptionsToOwner();
|
||||
assert.equal((await loadSubscriptions("owner"))[0].id, "KEEP");
|
||||
});
|
||||
});
|
||||
@@ -207,7 +207,11 @@ describe("retryGemini", () => {
|
||||
},
|
||||
{ retries: 2, delayMs: 1, label: "test", log: (msg) => logs.push(msg) }
|
||||
).catch(() => {});
|
||||
assert.equal(logs.length, 1);
|
||||
// retries: 2 → the loop logs twice: the "failed, retrying in …s" notice
|
||||
// before attempt 2's wait, then "Retrying… (attempt 2/2)" at the top of
|
||||
// attempt 2. (The test previously expected 1, written before the
|
||||
// top-of-attempt retry line existed.)
|
||||
assert.equal(logs.length, 2);
|
||||
assert.match(logs[0], /test/);
|
||||
assert.match(logs[0], /retrying/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// Audio-first ("walking mode") TTS routes. Turns a saved recap's per-topic
|
||||
// summaries into spoken MP3 clips (via the relay's /relay/tts → Kokoro),
|
||||
// caches them next to the session JSON, and serves them to the player.
|
||||
//
|
||||
// Endpoints (all scope-isolated to the requesting user's library):
|
||||
// GET /api/tts/availability — { has_tts, allowed, default_voice }
|
||||
// POST /api/tts/generate/:id/:index — synthesize + cache ONE topic clip
|
||||
// (idempotent, retried, deduped by job
|
||||
// id). The player calls this on demand
|
||||
// as it reaches each topic + prefetches
|
||||
// the next, so clips are generated when
|
||||
// needed and retried until they succeed
|
||||
// rather than skipped.
|
||||
// GET /api/tts/status/:id — { total, ready:[idx...], done }
|
||||
// GET /api/tts/audio/:id/:index — serve a cached topic clip (mp3)
|
||||
//
|
||||
// Access policy (the "Max gate"):
|
||||
// - single mode: the operator owns the box AND the TTS hardware, so no
|
||||
// tier gate — TTS is available whenever the relay advertises has_tts.
|
||||
// - multi mode admin: the operator; allowed.
|
||||
// - multi-tenant cloud users: any paid subscription (Pro or Max). The
|
||||
// operator can tighten this to Max-only here if shared TTS hardware
|
||||
// throughput becomes a constraint.
|
||||
//
|
||||
// Billing: all of a recap's topics share ONE relay job id (`tts:<id>`), so
|
||||
// the relay charges at most 1 credit to voice an entire recap.
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
import {
|
||||
scopeForRequest,
|
||||
sessionAudioDir,
|
||||
loadSession,
|
||||
patchSession,
|
||||
} from "./history.js";
|
||||
import { getProvider, resolveProviderOpts } from "./providers/index.js";
|
||||
import { getRelayCapabilities } from "./relay-capabilities.js";
|
||||
|
||||
const CLIP_FORMAT = "mp3";
|
||||
const CLIP_EXT = "mp3";
|
||||
|
||||
// Whether THIS request's user may generate TTS. See the policy note above.
|
||||
export function userHasTtsAccess(req) {
|
||||
// Single mode (or no request context): operator owns the hardware.
|
||||
if (!req || req.recapMode !== "multi") return true;
|
||||
// Multi-mode admin = the operator.
|
||||
if (req.user && req.user.is_admin) return true;
|
||||
// Multi-tenant cloud user: Pro or Max. Core-decoupling — the tier is the
|
||||
// relay-owned subscription tier, cached on the Recaps account
|
||||
// (req.user.tier), kept in sync by the operator grant flow.
|
||||
const tier = req.user?.tier;
|
||||
return tier === "pro" || tier === "max";
|
||||
}
|
||||
|
||||
// The text we speak for a topic: its title as a lead-in, then the summary,
|
||||
// so an eyes-free listener hears what the topic is before its recap.
|
||||
export function chunkSpeechText(chunk) {
|
||||
const title = (chunk?.title || "").trim();
|
||||
const summary = (chunk?.summary || "").trim();
|
||||
if (title && summary) return `${title}. ${summary}`;
|
||||
return summary || title || "";
|
||||
}
|
||||
|
||||
function clipFileName(index) {
|
||||
return `topic-${index}.${CLIP_EXT}`;
|
||||
}
|
||||
|
||||
// Server-side retries per clip on a transient (5xx/network) relay failure,
|
||||
// on top of any retry the relay itself does.
|
||||
const GEN_RETRIES = 2;
|
||||
|
||||
// Generate + cache ONE topic clip. Idempotent: returns {cached:true} if the
|
||||
// file already exists. Retries transient failures; a 4xx (e.g. bad voice) or
|
||||
// empty summary is permanent (no retry). Returns
|
||||
// { ok, cached?, empty?, error?, voice? }.
|
||||
async function generateClip({ scope, id, index, chunk, provider, jobId, voice }) {
|
||||
const dir = sessionAudioDir(scope, id);
|
||||
const file = path.join(dir, clipFileName(index));
|
||||
try {
|
||||
await fs.access(file);
|
||||
return { ok: true, cached: true };
|
||||
} catch {}
|
||||
const text = chunkSpeechText(chunk);
|
||||
if (!text) return { ok: false, empty: true, error: "empty_summary" };
|
||||
await fs.mkdir(dir, { recursive: true }).catch(() => {});
|
||||
let lastErr = null;
|
||||
for (let attempt = 1; attempt <= GEN_RETRIES + 1; attempt++) {
|
||||
try {
|
||||
const r = await provider.tts({ text, voice, format: CLIP_FORMAT, jobId });
|
||||
await fs.writeFile(file, r.audio);
|
||||
return { ok: true, voice: r.voice, backend: r.backend };
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const status = err?.status || 0;
|
||||
console.warn(
|
||||
`[tts] clip ${index} attempt ${attempt}/${GEN_RETRIES + 1} failed (${status || "net"}): ${err?.message || err}`,
|
||||
);
|
||||
if (status >= 400 && status < 500) break; // client error → permanent
|
||||
if (attempt <= GEN_RETRIES) await new Promise((r2) => setTimeout(r2, 600));
|
||||
}
|
||||
}
|
||||
return { ok: false, error: (lastErr?.message || "tts_failed").slice(0, 200) };
|
||||
}
|
||||
|
||||
function resolveScope(req, res) {
|
||||
try {
|
||||
return scopeForRequest(req);
|
||||
} catch {
|
||||
res.status(401).json({ error: "no_scope" });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupTtsRoutes(app) {
|
||||
// Lightweight probe for the frontend: should it show the "Listen"
|
||||
// affordance, and what's the default voice?
|
||||
app.get("/api/tts/availability", (req, res) => {
|
||||
const caps = getRelayCapabilities();
|
||||
res.json({
|
||||
has_tts: !!caps.has_tts,
|
||||
tts_backend: caps.tts_backend || null,
|
||||
default_voice: caps.tts_default_voice || null,
|
||||
allowed: userHasTtsAccess(req) && !!caps.has_tts,
|
||||
});
|
||||
});
|
||||
|
||||
// Generate (or return cached) the audio for ONE topic. The player calls
|
||||
// this on demand as it reaches each topic — and prefetches the next — so a
|
||||
// clip is generated when needed and RETRIED until it succeeds, rather than
|
||||
// skipped. Idempotent + deduped by the shared job id (≤1 credit/recap).
|
||||
//
|
||||
// Responses:
|
||||
// 200 { ok:true, index, cached } — clip is ready to play
|
||||
// 200 { ok:false, empty:true } — topic has no summary text (permanent;
|
||||
// client should not retry)
|
||||
// 502 { ok:false, error } — transient failure; client retries
|
||||
app.post("/api/tts/generate/:id/:index", async (req, res) => {
|
||||
const scope = resolveScope(req, res);
|
||||
if (!scope) return;
|
||||
if (!userHasTtsAccess(req)) {
|
||||
return res.status(403).json({
|
||||
error: "tts_requires_subscription",
|
||||
message: "Audio recaps are available to Pro and Max subscribers.",
|
||||
});
|
||||
}
|
||||
const caps = getRelayCapabilities();
|
||||
if (!caps.has_tts) {
|
||||
return res.status(503).json({
|
||||
error: "tts_unavailable",
|
||||
message: "Text-to-speech isn't available on this relay right now.",
|
||||
});
|
||||
}
|
||||
const id = req.params.id;
|
||||
const index = parseInt(req.params.index, 10);
|
||||
const session = await loadSession(scope, id);
|
||||
if (!session) return res.status(404).json({ error: "session_not_found" });
|
||||
const chunks = Array.isArray(session.chunks) ? session.chunks : [];
|
||||
if (!Number.isInteger(index) || index < 0 || index >= chunks.length) {
|
||||
return res.status(400).json({ error: "bad_index" });
|
||||
}
|
||||
let provider;
|
||||
try {
|
||||
provider = getProvider("relay", resolveProviderOpts("relay", { req }));
|
||||
} catch (err) {
|
||||
return res.status(503).json({
|
||||
error: "relay_unavailable",
|
||||
message: err?.message || "Relay is not configured.",
|
||||
});
|
||||
}
|
||||
const voice =
|
||||
typeof req.query.voice === "string" && req.query.voice.trim()
|
||||
? req.query.voice.trim()
|
||||
: undefined;
|
||||
const result = await generateClip({
|
||||
scope,
|
||||
id,
|
||||
index,
|
||||
chunk: chunks[index],
|
||||
provider,
|
||||
jobId: `tts:${id}`, // one credit for the whole recap
|
||||
voice,
|
||||
});
|
||||
if (result.ok) {
|
||||
patchSession(scope, id, {
|
||||
summaryAudio: {
|
||||
ready: true,
|
||||
total: chunks.length,
|
||||
voice: result.voice || caps.tts_default_voice || null,
|
||||
format: CLIP_FORMAT,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}).catch(() => {});
|
||||
return res.json({ ok: true, index, cached: !!result.cached, voice: result.voice || null });
|
||||
}
|
||||
if (result.empty) {
|
||||
return res.json({ ok: false, index, empty: true, error: "empty_summary" });
|
||||
}
|
||||
return res.status(502).json({ ok: false, index, error: result.error || "tts_failed" });
|
||||
});
|
||||
|
||||
// Which topics are already synthesized for a recap.
|
||||
app.get("/api/tts/status/:id", async (req, res) => {
|
||||
const scope = resolveScope(req, res);
|
||||
if (!scope) return;
|
||||
const session = await loadSession(scope, req.params.id);
|
||||
if (!session) return res.status(404).json({ error: "session_not_found" });
|
||||
const total = Array.isArray(session.chunks) ? session.chunks.length : 0;
|
||||
const dir = sessionAudioDir(scope, req.params.id);
|
||||
let files = [];
|
||||
try {
|
||||
files = await fs.readdir(dir);
|
||||
} catch {}
|
||||
const ready = files
|
||||
.map((f) => {
|
||||
const m = new RegExp(`^topic-(\\d+)\\.${CLIP_EXT}$`).exec(f);
|
||||
return m ? Number(m[1]) : null;
|
||||
})
|
||||
.filter((n) => n !== null)
|
||||
.sort((a, b) => a - b);
|
||||
const caps = getRelayCapabilities();
|
||||
res.json({
|
||||
total,
|
||||
ready,
|
||||
done: total > 0 && ready.length >= total,
|
||||
allowed: userHasTtsAccess(req) && !!caps.has_tts,
|
||||
voice: session.summaryAudio?.voice || caps.tts_default_voice || null,
|
||||
});
|
||||
});
|
||||
|
||||
// Serve one cached topic clip. sendFile handles Range requests (so the
|
||||
// <audio> element can seek) and 404s cleanly when the clip isn't ready.
|
||||
app.get("/api/tts/audio/:id/:index", async (req, res) => {
|
||||
const scope = resolveScope(req, res);
|
||||
if (!scope) return;
|
||||
const idx = parseInt(req.params.index, 10);
|
||||
if (!Number.isInteger(idx) || idx < 0) {
|
||||
return res.status(400).json({ error: "bad_index" });
|
||||
}
|
||||
const file = path.join(sessionAudioDir(scope, req.params.id), clipFileName(idx));
|
||||
res.sendFile(
|
||||
file,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "audio/mpeg",
|
||||
// Content is immutable for a given (session, topic) — safe to
|
||||
// cache hard, which also primes the Phase 4 offline service worker.
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
},
|
||||
},
|
||||
(err) => {
|
||||
if (err && !res.headersSent) {
|
||||
res.status(404).json({ error: "clip_not_ready" });
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// Resolves "share URLs" from Apple Podcasts and Spotify into something
|
||||
// Recap's existing podcast pipeline can swallow. Most users share these
|
||||
// links rather than the underlying RSS feed (which they rarely know
|
||||
// exists), so transparent resolution turns the most common podcast
|
||||
// share path into "paste link, hit summarize, done".
|
||||
//
|
||||
// Apple Podcasts URLs resolve directly via the public iTunes Lookup API:
|
||||
// the episode result includes `episodeUrl` (the audio enclosure) and
|
||||
// the show's `feedUrl`. No API key required, no auth.
|
||||
//
|
||||
// Spotify URLs are harder: Spotify-hosted audio is DRM-wrapped and not
|
||||
// served via a public stream URL. We use the unauthenticated oEmbed
|
||||
// endpoint to get the episode + show titles, then ask PodcastIndex to
|
||||
// find the same episode in its RSS-indexed catalog. Spotify Originals
|
||||
// (Joe Rogan, Anchor exclusives, …) have no RSS counterpart and fail
|
||||
// the lookup — we surface a clear error in that case so the user
|
||||
// understands and can paste the RSS link manually.
|
||||
//
|
||||
// Returns a normalized shape that maps cleanly onto Recap's existing
|
||||
// podcast pipeline:
|
||||
// {
|
||||
// source: "apple" | "spotify",
|
||||
// audioUrl: string, // direct audio URL (.mp3/.m4a) — feeds the existing podcast path
|
||||
// episodeId: string, // stable GUID used by history dedup
|
||||
// title: string,
|
||||
// podcastTitle: string,
|
||||
// uploadDate: string, // "YYYYMMDD"
|
||||
// durationSec: number?, // null when unknown
|
||||
// feedUrl: string?, // for context; not required downstream
|
||||
// }
|
||||
//
|
||||
// Throws `URLResolveError` with a `.code` field for things the UI may
|
||||
// want to format specifically:
|
||||
// - "spotify_no_rss" → episode is Spotify-exclusive
|
||||
// - "episode_not_found" → looked up but couldn't match
|
||||
// - "apple_lookup_failed"
|
||||
// - "podcastindex_unconfigured"
|
||||
// - "podcastindex_not_implemented" → caller didn't pass keys
|
||||
|
||||
import crypto from "crypto";
|
||||
import { fetchUrl } from "./util.js";
|
||||
|
||||
export class URLResolveError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.name = "URLResolveError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const APPLE_EPISODE_URL_RE =
|
||||
/^https?:\/\/(?:www\.)?podcasts\.apple\.com\/[^/]+\/podcast\/[^/]+\/id(\d+)(?:\?|.*[?&])i=(\d+)/i;
|
||||
|
||||
// Detection only — no I/O.
|
||||
export function isApplePodcastUrl(url) {
|
||||
if (!url) return false;
|
||||
return /^https?:\/\/(?:www\.)?podcasts\.apple\.com\//i.test(url);
|
||||
}
|
||||
|
||||
export function isSpotifyUrl(url) {
|
||||
if (!url) return false;
|
||||
return /^https?:\/\/(?:open|play)\.spotify\.com\/(?:episode|show)\//i.test(url);
|
||||
}
|
||||
|
||||
// Fountain (https://fountain.fm) is a Bitcoin-Lightning podcast app
|
||||
// that hosts a Podcasting 2.0-native catalog. Episode pages are at
|
||||
// /episode/<short-id>; the underlying media is served from
|
||||
// feeds.fountain.fm and exposed via standard Open Graph tags
|
||||
// (og:audio, og:image, og:title) on the public episode HTML — no API
|
||||
// key required to resolve. Show pages (/show/<id>) aren't supported
|
||||
// for now; users should paste a specific episode link.
|
||||
export function isFountainUrl(url) {
|
||||
if (!url) return false;
|
||||
return /^https?:\/\/(?:www\.)?fountain\.fm\/episode\//i.test(url);
|
||||
}
|
||||
|
||||
// True if the URL is one of the "share link" forms we know how to turn
|
||||
// into a podcast audio URL. Callers should only invoke the network-
|
||||
// touching resolvers when this returns true.
|
||||
export function isResolvableShareUrl(url) {
|
||||
return isApplePodcastUrl(url) || isSpotifyUrl(url) || isFountainUrl(url);
|
||||
}
|
||||
|
||||
// ── Apple Podcasts ─────────────────────────────────────────────────────
|
||||
// Strategy: parse the podcast ID + episode track ID out of the URL,
|
||||
// hit iTunes Lookup, find the matching episode by trackId. Apple
|
||||
// returns the episode's actual audio enclosure URL — same URL the
|
||||
// Apple Podcasts app streams from — so the existing podcast download
|
||||
// pipeline (audio.downloadPodcastAudio) can swallow it unchanged.
|
||||
export async function resolveApplePodcastUrl(url) {
|
||||
const m = url.match(APPLE_EPISODE_URL_RE);
|
||||
if (!m) {
|
||||
throw new URLResolveError(
|
||||
"apple_lookup_failed",
|
||||
"Apple Podcasts URL is missing podcast ID or episode ID (?i= param)"
|
||||
);
|
||||
}
|
||||
const podcastId = m[1];
|
||||
const episodeTrackId = m[2];
|
||||
|
||||
// The lookup endpoint returns the show metadata as result[0] and the
|
||||
// most-recent N episodes as result[1..]. Apple silently caps at 200
|
||||
// even if you ask for more.
|
||||
const lookupUrl = `https://itunes.apple.com/lookup?id=${encodeURIComponent(
|
||||
podcastId
|
||||
)}&entity=podcastEpisode&limit=200`;
|
||||
let parsed;
|
||||
try {
|
||||
const raw = await fetchUrl(lookupUrl);
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new URLResolveError(
|
||||
"apple_lookup_failed",
|
||||
`iTunes lookup failed: ${err?.message || err}`
|
||||
);
|
||||
}
|
||||
const results = Array.isArray(parsed?.results) ? parsed.results : [];
|
||||
const show = results.find((r) => r.wrapperType === "track" || r.kind === "podcast") || {};
|
||||
const episode = results.find(
|
||||
(r) =>
|
||||
r.wrapperType === "podcastEpisode" &&
|
||||
String(r.trackId) === String(episodeTrackId)
|
||||
);
|
||||
if (!episode || !episode.episodeUrl) {
|
||||
throw new URLResolveError(
|
||||
"episode_not_found",
|
||||
`Apple returned ${results.length} results for podcast ${podcastId} but episode ${episodeTrackId} was not among them. The episode may be older than Apple's 200-episode lookup cap.`
|
||||
);
|
||||
}
|
||||
|
||||
// releaseDate is ISO 8601; collapse to YYYYMMDD to match the rest of
|
||||
// the pipeline's date convention.
|
||||
let uploadDate = "";
|
||||
if (episode.releaseDate) {
|
||||
try {
|
||||
const d = new Date(episode.releaseDate);
|
||||
if (!isNaN(d.getTime())) {
|
||||
uploadDate = d.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const durationSec =
|
||||
typeof episode.trackTimeMillis === "number"
|
||||
? Math.round(episode.trackTimeMillis / 1000)
|
||||
: null;
|
||||
|
||||
return {
|
||||
source: "apple",
|
||||
audioUrl: episode.episodeUrl,
|
||||
episodeId: episode.episodeGuid || `apple-${episodeTrackId}`,
|
||||
title: episode.trackName || show.collectionName || "Untitled episode",
|
||||
podcastTitle: show.collectionName || episode.collectionName || "Unknown podcast",
|
||||
uploadDate,
|
||||
durationSec,
|
||||
feedUrl: show.feedUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Spotify ────────────────────────────────────────────────────────────
|
||||
// Strategy: oEmbed for title/show, then PodcastIndex search to map
|
||||
// title+show → RSS feed → episode → audio enclosure. Spotify-exclusive
|
||||
// content has no RSS counterpart and fails the lookup with a clear
|
||||
// `spotify_no_rss` error.
|
||||
//
|
||||
// PodcastIndex auth (https://podcastindex.org/api/dev) requires:
|
||||
// - `User-Agent` header (PodcastIndex blocks anonymous UAs)
|
||||
// - `X-Auth-Key`: API key (free, signup at api.podcastindex.org)
|
||||
// - `X-Auth-Date`: unix timestamp (current time)
|
||||
// - `Authorization`: sha1(apiKey + apiSecret + apiDate)
|
||||
export async function resolveSpotifyUrl(url, { podcastIndexKey, podcastIndexSecret } = {}) {
|
||||
if (!podcastIndexKey || !podcastIndexSecret) {
|
||||
throw new URLResolveError(
|
||||
"podcastindex_unconfigured",
|
||||
'Spotify needs both a free PodcastIndex API Key AND API Secret. Sign up at api.podcastindex.org — your account page shows both credentials side-by-side. Paste them in Recaps → Settings → API Keys → PodcastIndex. (Apple Podcasts and Fountain links work without any API key — try those for the same episode if it\'s also distributed there.)'
|
||||
);
|
||||
}
|
||||
|
||||
// oEmbed gives us episode title + show name with no auth.
|
||||
let episodeTitle = "";
|
||||
let showName = "";
|
||||
try {
|
||||
const oemRaw = await fetchUrl(
|
||||
`https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`
|
||||
);
|
||||
const oem = JSON.parse(oemRaw);
|
||||
episodeTitle = (oem?.title || "").trim();
|
||||
// oEmbed's "title" includes the show name in some Spotify variants
|
||||
// (e.g. "<episode> · <show>"). Split if we see the delimiter.
|
||||
const sep = episodeTitle.lastIndexOf(" · ");
|
||||
if (sep > 0) {
|
||||
showName = episodeTitle.slice(sep + 3).trim();
|
||||
episodeTitle = episodeTitle.slice(0, sep).trim();
|
||||
}
|
||||
} catch (err) {
|
||||
throw new URLResolveError(
|
||||
"episode_not_found",
|
||||
`Could not fetch Spotify episode metadata: ${err?.message || err}`
|
||||
);
|
||||
}
|
||||
if (!episodeTitle) {
|
||||
throw new URLResolveError(
|
||||
"episode_not_found",
|
||||
"Spotify oEmbed returned no episode title"
|
||||
);
|
||||
}
|
||||
|
||||
// Authoritative PodcastIndex search uses byperson/bypath/byterm. The
|
||||
// episode-search endpoint accepts a free-text query and returns
|
||||
// candidate episodes across the index.
|
||||
const q = encodeURIComponent(
|
||||
showName ? `${episodeTitle} ${showName}` : episodeTitle
|
||||
);
|
||||
const searchUrl = `https://api.podcastindex.org/api/1.0/search/byterm?q=${q}&max=5`;
|
||||
|
||||
let candidate = null;
|
||||
try {
|
||||
const headers = buildPodcastIndexHeaders({ podcastIndexKey, podcastIndexSecret });
|
||||
const r = await fetch(searchUrl, { headers });
|
||||
const data = await r.json();
|
||||
const feeds = Array.isArray(data?.feeds) ? data.feeds : [];
|
||||
// Best-match heuristic: prefer a feed whose title fuzzy-matches the
|
||||
// show name, fall back to the first result.
|
||||
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
const showKey = norm(showName);
|
||||
candidate =
|
||||
(showKey && feeds.find((f) => norm(f.title).includes(showKey))) ||
|
||||
feeds[0] ||
|
||||
null;
|
||||
} catch (err) {
|
||||
throw new URLResolveError(
|
||||
"episode_not_found",
|
||||
`PodcastIndex feed search failed: ${err?.message || err}`
|
||||
);
|
||||
}
|
||||
if (!candidate || !candidate.id) {
|
||||
throw new URLResolveError(
|
||||
"spotify_no_rss",
|
||||
`This Spotify episode "${episodeTitle}" doesn't appear in PodcastIndex. It may be a Spotify exclusive (Spotify Originals, Anchor-only shows). Paste the show's RSS feed URL instead, or use the YouTube version if available.`
|
||||
);
|
||||
}
|
||||
|
||||
// Pull the episode list for the matched feed and find the closest
|
||||
// title match.
|
||||
let episodes = [];
|
||||
try {
|
||||
const headers = buildPodcastIndexHeaders({ podcastIndexKey, podcastIndexSecret });
|
||||
const r = await fetch(
|
||||
`https://api.podcastindex.org/api/1.0/episodes/byfeedid?id=${candidate.id}&max=200`,
|
||||
{ headers }
|
||||
);
|
||||
const data = await r.json();
|
||||
episodes = Array.isArray(data?.items) ? data.items : [];
|
||||
} catch (err) {
|
||||
throw new URLResolveError(
|
||||
"episode_not_found",
|
||||
`PodcastIndex episode lookup failed: ${err?.message || err}`
|
||||
);
|
||||
}
|
||||
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
const targetKey = norm(episodeTitle);
|
||||
const episode =
|
||||
episodes.find((e) => norm(e.title) === targetKey) ||
|
||||
episodes.find((e) => norm(e.title).includes(targetKey)) ||
|
||||
episodes.find((e) => targetKey.includes(norm(e.title))) ||
|
||||
null;
|
||||
if (!episode || !episode.enclosureUrl) {
|
||||
throw new URLResolveError(
|
||||
"episode_not_found",
|
||||
`Matched "${candidate.title}" in PodcastIndex but couldn't find an episode titled "${episodeTitle}". Episode may be too new for PodcastIndex's snapshot, or only available on Spotify.`
|
||||
);
|
||||
}
|
||||
|
||||
let uploadDate = "";
|
||||
if (episode.datePublished) {
|
||||
try {
|
||||
const d = new Date(episode.datePublished * 1000);
|
||||
if (!isNaN(d.getTime())) {
|
||||
uploadDate = d.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
source: "spotify",
|
||||
audioUrl: episode.enclosureUrl,
|
||||
episodeId: episode.guid || `spotify-${episode.id}`,
|
||||
title: episode.title || episodeTitle,
|
||||
podcastTitle: candidate.title || showName || "Unknown podcast",
|
||||
uploadDate,
|
||||
durationSec:
|
||||
typeof episode.duration === "number" ? episode.duration : null,
|
||||
feedUrl: candidate.url || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Fountain ───────────────────────────────────────────────────────────
|
||||
// Strategy: fetch the episode HTML, parse Open Graph + JSON-LD tags
|
||||
// for the audio URL, title, podcast name, and upload date. Fountain
|
||||
// serves the actual MP3 enclosure URL on og:audio so we don't need
|
||||
// any PodcastIndex lookup or API key. Same shape as the Apple
|
||||
// resolver returns so the downstream podcast pipeline doesn't have
|
||||
// to branch.
|
||||
//
|
||||
// The og:title format is "Show • Episode title • Watch on Fountain".
|
||||
// We split on " • " to separate show + episode; the trailing "Watch
|
||||
// on Fountain" branding gets dropped.
|
||||
//
|
||||
// Fountain URLs encode the episode in a short opaque id at the URL
|
||||
// path; we use that as our episodeId for history dedup.
|
||||
export async function resolveFountainUrl(url) {
|
||||
const m = url.match(/\/episode\/([A-Za-z0-9_-]+)/);
|
||||
if (!m) {
|
||||
throw new URLResolveError(
|
||||
"fountain_lookup_failed",
|
||||
"Fountain URL is missing the /episode/<id> path",
|
||||
);
|
||||
}
|
||||
const shortId = m[1];
|
||||
|
||||
// Use global fetch directly (Node 18+) so we can send a UA header.
|
||||
// fetchUrl() in util.js doesn't take options; we don't want to
|
||||
// expand its signature just for this one caller. Fountain's SSR
|
||||
// response includes the og:audio tag we need for ANY UA, but mimic
|
||||
// a modern Safari to stay on the well-tested response path.
|
||||
let html;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
|
||||
},
|
||||
// Aggressive timeout — Fountain's page is small (~120KB) and
|
||||
// we shouldn't hold up the summarize pipeline if their server
|
||||
// hangs. AbortSignal.timeout is Node 18+, same baseline.
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new URLResolveError(
|
||||
"fountain_lookup_failed",
|
||||
`Fountain returned HTTP ${res.status}`,
|
||||
);
|
||||
}
|
||||
html = await res.text();
|
||||
} catch (err) {
|
||||
if (err instanceof URLResolveError) throw err;
|
||||
throw new URLResolveError(
|
||||
"fountain_lookup_failed",
|
||||
`Couldn't reach Fountain: ${err?.message || err}`,
|
||||
);
|
||||
}
|
||||
if (!html || typeof html !== "string") {
|
||||
throw new URLResolveError(
|
||||
"fountain_lookup_failed",
|
||||
"Empty response from Fountain",
|
||||
);
|
||||
}
|
||||
|
||||
// Extract a meta tag's content by either property= or name=.
|
||||
// Fountain uses property= for OG and name= for Twitter — we match
|
||||
// both since users might paste links Twitter has re-fetched.
|
||||
function metaContent(key) {
|
||||
const re = new RegExp(
|
||||
`<meta\\s+(?:property|name)="${key}"\\s+content="([^"]+)"`,
|
||||
"i",
|
||||
);
|
||||
const found = html.match(re);
|
||||
return found ? decodeHtmlEntities(found[1]) : null;
|
||||
}
|
||||
|
||||
const audioUrl = metaContent("og:audio");
|
||||
if (!audioUrl) {
|
||||
throw new URLResolveError(
|
||||
"fountain_lookup_failed",
|
||||
"Fountain episode has no og:audio tag — the page may have changed format or the episode is video-only.",
|
||||
);
|
||||
}
|
||||
|
||||
const ogTitleRaw = metaContent("og:title") || "";
|
||||
// og:title format: "Show • Episode • Watch on Fountain". Strip the
|
||||
// trailing brand and split.
|
||||
const titlePieces = ogTitleRaw
|
||||
.replace(/\s*•\s*Watch on Fountain\s*$/i, "")
|
||||
.split(/\s*•\s*/);
|
||||
const podcastTitle = titlePieces[0] || "Podcast";
|
||||
const episodeTitle = titlePieces.slice(1).join(" • ") || podcastTitle;
|
||||
|
||||
// JSON-LD on the page carries an ISO uploadDate. We don't parse
|
||||
// the full JSON; a targeted regex is enough.
|
||||
const uploadDateMatch = html.match(/"uploadDate":"([^"]+)"/);
|
||||
const uploadDateRaw = uploadDateMatch ? uploadDateMatch[1] : "";
|
||||
const uploadDate = isoToYYYYMMDD(uploadDateRaw);
|
||||
|
||||
// ISO-8601 duration (e.g. "PT2H7M27S") → seconds. Optional —
|
||||
// returns null if absent.
|
||||
const durationMatch = html.match(/"duration":"(PT[0-9HMS]+)"/);
|
||||
const durationSec = durationMatch
|
||||
? iso8601DurationToSeconds(durationMatch[1])
|
||||
: null;
|
||||
|
||||
return {
|
||||
source: "fountain",
|
||||
audioUrl,
|
||||
episodeId: `fountain:${shortId}`,
|
||||
title: episodeTitle,
|
||||
podcastTitle,
|
||||
uploadDate,
|
||||
durationSec,
|
||||
feedUrl: null, // Fountain doesn't always expose the source RSS URL
|
||||
};
|
||||
}
|
||||
|
||||
// "2026-05-07T20:53:13.003Z" → "20260507". Returns empty string on
|
||||
// unparseable input so the downstream pipeline treats it as unknown.
|
||||
function isoToYYYYMMDD(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${y}${m}${day}`;
|
||||
}
|
||||
|
||||
// "PT2H7M27S" → 7647 seconds. Handles the subset of ISO-8601 that
|
||||
// podcasts actually use (no fractional, no days).
|
||||
function iso8601DurationToSeconds(s) {
|
||||
if (typeof s !== "string") return null;
|
||||
const m = s.match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/);
|
||||
if (!m) return null;
|
||||
const h = parseInt(m[1] || "0", 10);
|
||||
const min = parseInt(m[2] || "0", 10);
|
||||
const sec = parseInt(m[3] || "0", 10);
|
||||
return h * 3600 + min * 60 + sec;
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function buildPodcastIndexHeaders({ podcastIndexKey, podcastIndexSecret }) {
|
||||
const date = Math.floor(Date.now() / 1000).toString();
|
||||
const sig = crypto
|
||||
.createHash("sha1")
|
||||
.update(podcastIndexKey + podcastIndexSecret + date)
|
||||
.digest("hex");
|
||||
return {
|
||||
"User-Agent": "Recap/1.0 (+https://github.com/keysat-xyz/recap)",
|
||||
"X-Auth-Key": podcastIndexKey,
|
||||
"X-Auth-Date": date,
|
||||
Authorization: sig,
|
||||
};
|
||||
}
|
||||
|
||||
// Single entry point: takes any URL and a config object, returns
|
||||
// either the normalized resolved shape (for apple/spotify) or null
|
||||
// (for URLs we don't recognize as share links — caller passes those
|
||||
// through to the existing youtube / rss path unchanged).
|
||||
export async function resolveShareUrl(url, opts = {}) {
|
||||
if (isApplePodcastUrl(url)) {
|
||||
return resolveApplePodcastUrl(url);
|
||||
}
|
||||
if (isSpotifyUrl(url)) {
|
||||
return resolveSpotifyUrl(url, opts);
|
||||
}
|
||||
if (isFountainUrl(url)) {
|
||||
return resolveFountainUrl(url);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// Multi-tenant mode (a.k.a. cloud mode) turns the self-hosted Recaps
|
||||
// into a multi-user app served over the web. Adds email + magic-link
|
||||
// auth, per-user libraries, per-user keysat licenses, and BTCPay-based
|
||||
// subscriptions. The .s9pk defaults to single-mode (one operator, no
|
||||
// auth) so installing Recaps doesn't surprise anyone — multi is the
|
||||
// deliberate opt-in for operators who want to host the app for others.
|
||||
//
|
||||
// Prerequisites before enabling:
|
||||
// 1. StartOS System SMTP must be configured + tested. Magic-link
|
||||
// emails fail otherwise.
|
||||
// 2. "Set Recaps public URL" action must be run with the ClearNet URL
|
||||
// where your Recaps will live (e.g. https://recap.example.com),
|
||||
// so the sign-in emails contain a working link.
|
||||
// 3. A keysat issuer the relay trusts must be reachable, since each
|
||||
// cloud user gets a keysat-minted license at signup.
|
||||
//
|
||||
// Switching back to single mode is non-destructive — multi-tenant data
|
||||
// (the user DB at /data/recap.db) stays on disk and is restored if you
|
||||
// flip back to multi later. The operator-owner's library lives under
|
||||
// /data/history/owner/ in either mode.
|
||||
const inputSpec = InputSpec.of({
|
||||
mode: Value.select({
|
||||
name: 'Mode',
|
||||
description:
|
||||
'Single = original self-hosted experience (one operator, no auth). Multi = cloud mode (email/magic-link auth, multi-user, BTCPay subscriptions). Switching takes effect on next service restart.',
|
||||
default: 'single',
|
||||
values: {
|
||||
single: 'Single (self-hosted, no accounts)',
|
||||
multi: 'Multi (cloud, email auth + subscriptions)',
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const enableMultiTenantMode = sdk.Action.withInput(
|
||||
'enable-multi-tenant-mode',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Enable Multi-Tenant Mode',
|
||||
description:
|
||||
'Switch Recaps between single-user (self-hosted) and multi-tenant (cloud) modes. Multi mode adds email-based sign-in, per-user libraries, and BTCPay subscriptions. Configure SMTP and the public URL before enabling.',
|
||||
warning:
|
||||
'Switching to multi mode requires StartOS SMTP to be configured and the Recaps public URL set. The service restarts on save.',
|
||||
allowedStatuses: 'any',
|
||||
group: 'Multi-Tenant',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return { mode: (config?.recap_mode as 'single' | 'multi') || 'single' }
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
await configFile.merge(effects, { recap_mode: input.mode })
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -7,10 +7,22 @@ import { setOpenAIApiKey } from './setOpenAIApiKey'
|
||||
import { setOpenAICompatible } from './setOpenAICompatible'
|
||||
import { setOllamaUrl } from './setOllamaUrl'
|
||||
import { setWhisperEndpoint } from './setWhisperEndpoint'
|
||||
import { setPodcastIndex } from './setPodcastIndex'
|
||||
import { enableMultiTenantMode } from './enableMultiTenantMode'
|
||||
import { setRecapPublicUrl } from './setRecapPublicUrl'
|
||||
import { setTenantDefaultCredits } from './setTenantDefaultCredits'
|
||||
import { setTrialCreditsPerVisitor } from './setTrialCreditsPerVisitor'
|
||||
import { setTrialsPerIpPerDay } from './setTrialsPerIpPerDay'
|
||||
import { setReplenishPeriod } from './setReplenishPeriod'
|
||||
import { setRelayOperatorKey } from './setRelayOperatorKey'
|
||||
|
||||
// NOTE: setRelayUrl was removed in 0.2.34. The relay base URL is now
|
||||
// hardcoded in server/relay-default.js and updated via Recap version
|
||||
// releases — end users should never see or configure it.
|
||||
//
|
||||
// Multi-tenant cloud-mode actions (added 0.2.77) — these only matter
|
||||
// when recap_mode === 'multi'. In single mode they're inert: the
|
||||
// fields they manipulate exist in the config but nothing reads them.
|
||||
export const actions = sdk.Actions.of()
|
||||
.addAction(setApiKey)
|
||||
.addAction(setAnthropicApiKey)
|
||||
@@ -18,5 +30,13 @@ export const actions = sdk.Actions.of()
|
||||
.addAction(setOpenAICompatible)
|
||||
.addAction(setOllamaUrl)
|
||||
.addAction(setWhisperEndpoint)
|
||||
.addAction(setPodcastIndex)
|
||||
.addAction(setLicense)
|
||||
.addAction(setAdminPassword)
|
||||
.addAction(enableMultiTenantMode)
|
||||
.addAction(setRecapPublicUrl)
|
||||
.addAction(setTenantDefaultCredits)
|
||||
.addAction(setTrialCreditsPerVisitor)
|
||||
.addAction(setTrialsPerIpPerDay)
|
||||
.addAction(setReplenishPeriod)
|
||||
.addAction(setRelayOperatorKey)
|
||||
|
||||
@@ -43,7 +43,7 @@ export const setAdminPassword = sdk.Action.withInput(
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Admin Password',
|
||||
description:
|
||||
'Set a username and password that gate the Recap web UI. Anyone visiting the site (LAN or clearnet) must log in before reaching the activation screen. Leave the password blank to disable the gate.',
|
||||
'Set a username and password that gate the Recaps web UI. Anyone visiting the site (LAN or clearnet) must log in before reaching the activation screen. Leave the password blank to disable the gate.',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: 'Setup',
|
||||
|
||||
@@ -5,9 +5,9 @@ const { InputSpec, Value } = sdk
|
||||
|
||||
const inputSpec = InputSpec.of({
|
||||
recap_license_key: Value.text({
|
||||
name: 'Recap License Key',
|
||||
name: 'Recaps License Key',
|
||||
description:
|
||||
'Paste your Recap license key here. Keys start with "LIC1-..." — get one from your Recap seller. (Keys are also accepted via the web UI activation screen.)',
|
||||
'Paste your Recaps license key here. Keys start with "LIC1-..." — get one from your Recaps seller. (Keys are also accepted via the web UI activation screen.)',
|
||||
required: true,
|
||||
default: null,
|
||||
masked: true,
|
||||
@@ -26,9 +26,9 @@ export const setLicense = sdk.Action.withInput(
|
||||
'set-license',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Recap License',
|
||||
name: 'Set Recaps License',
|
||||
description:
|
||||
'Activate a Recap license to unlock paid features (channel & podcast subscriptions, auto-queue, and a monthly allotment of relay credits).',
|
||||
'Activate a Recaps license to unlock paid features (channel & podcast subscriptions, auto-queue, and a monthly allotment of relay credits).',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: 'Setup',
|
||||
|
||||
@@ -37,7 +37,7 @@ export const setOpenAICompatible = sdk.Action.withInput(
|
||||
async ({ effects }) => ({
|
||||
name: 'Set OpenAI-Compatible Backend',
|
||||
description:
|
||||
'Point Recap at any OpenAI-compatible chat-completions API: DeepSeek, Together, Groq, Fireworks, self-hosted vLLM, etc. Used for topic analysis only — does not transcribe audio.',
|
||||
'Point Recaps at any OpenAI-compatible chat-completions API: DeepSeek, Together, Groq, Fireworks, self-hosted vLLM, etc. Used for topic analysis only — does not transcribe audio.',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: 'AI Providers',
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const inputSpec = InputSpec.of({
|
||||
podcastindex_api_key: Value.text({
|
||||
name: 'PodcastIndex API Key',
|
||||
description:
|
||||
'First of the two credentials shown on your PodcastIndex account page after free signup at api.podcastindex.org. Both Key AND Secret are required for Spotify link resolution — paste the SECRET in the field below. Apple Podcasts and Fountain links work without any PodcastIndex auth.',
|
||||
required: false,
|
||||
default: null,
|
||||
masked: true,
|
||||
minLength: 0,
|
||||
maxLength: 256,
|
||||
}),
|
||||
podcastindex_api_secret: Value.text({
|
||||
name: 'PodcastIndex API Secret',
|
||||
description:
|
||||
'Second of the two credentials shown on your PodcastIndex account page (right next to the API Key — sometimes labeled "API Secret" or "auth secret"). REQUIRED alongside the API Key for the Spotify lookup to work — leaving this blank is the most common reason Spotify URLs fail with "PodcastIndex unconfigured."',
|
||||
required: false,
|
||||
default: null,
|
||||
masked: true,
|
||||
minLength: 0,
|
||||
maxLength: 256,
|
||||
}),
|
||||
})
|
||||
|
||||
export const setPodcastIndex = sdk.Action.withInput(
|
||||
'set-podcastindex',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set PodcastIndex Credentials',
|
||||
description:
|
||||
'Configure PodcastIndex API credentials so Recaps can resolve Spotify episode links. Optional — Apple Podcasts links work without this. Sign up free at api.podcastindex.org.',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: 'External Services',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return {
|
||||
podcastindex_api_key: config?.podcastindex_api_key || undefined,
|
||||
podcastindex_api_secret: config?.podcastindex_api_secret || undefined,
|
||||
}
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
await configFile.merge(effects, {
|
||||
podcastindex_api_key: input.podcastindex_api_key || '',
|
||||
podcastindex_api_secret: input.podcastindex_api_secret || '',
|
||||
})
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// The Recaps public URL is the ClearNet URL where users access the app
|
||||
// — typically a domain you've pointed at Start Tunnel. Magic-link
|
||||
// sign-in emails interpolate this URL into the verification link:
|
||||
//
|
||||
// Click here to sign in: https://recap.example.com/auth/verify?token=...
|
||||
//
|
||||
// Without this set, magic-link emails contain a link to localhost or
|
||||
// to the StartOS internal hostname, neither of which work for a user
|
||||
// reading the email on a different device.
|
||||
//
|
||||
// Only meaningful when recap_mode === 'multi'. In single mode the
|
||||
// value is ignored — there's no magic-link flow.
|
||||
const inputSpec = InputSpec.of({
|
||||
public_url: Value.text({
|
||||
name: 'Public URL',
|
||||
description:
|
||||
'Full URL where users reach your Recaps (e.g. https://recapapp.xyz). Include the https:// prefix. Used to build sign-in links in magic-link emails. Must be reachable from the public internet for users to receive a working link.',
|
||||
required: true,
|
||||
default: null,
|
||||
placeholder: 'https://recapapp.xyz',
|
||||
masked: false,
|
||||
minLength: 8,
|
||||
maxLength: 256,
|
||||
}),
|
||||
})
|
||||
|
||||
export const setRecapPublicUrl = sdk.Action.withInput(
|
||||
'set-recap-public-url',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Recaps Public URL',
|
||||
description:
|
||||
'Set the ClearNet URL where users will access your Recaps. Used in magic-link sign-in emails. Required for multi-tenant mode.',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: 'Multi-Tenant',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return { public_url: config?.recap_public_url || undefined }
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
// Strip trailing slash for canonical form — the auth-link builder
|
||||
// concatenates "/auth/verify?token=..." without checking.
|
||||
const url = (input.public_url || '').trim().replace(/\/$/, '')
|
||||
await configFile.merge(effects, { recap_public_url: url })
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// The "operator key" is the shared secret that authenticates THIS Recaps
|
||||
// server to the Recap Relay for the core-decoupling cloud path. With it
|
||||
// set, the server vouches for its signed-in Pro/Max users by their Recaps
|
||||
// account-id (X-Recap-User-Id) — the relay owns their subscription tier,
|
||||
// keyed by that id, with NO per-user Keysat license involved.
|
||||
//
|
||||
// It MUST exactly match the value set on the relay (its
|
||||
// relay_cloud_operator_key, via the relay's own "Set Cloud Operator Key"
|
||||
// action). If the two don't match, the relay rejects the cloud calls and
|
||||
// paid users silently fall back to the operator's shared relay pool.
|
||||
//
|
||||
// Only meaningful when recap_mode === 'multi' (the cloud deployment). In
|
||||
// single mode there are no per-user accounts to vouch for, so the value
|
||||
// is ignored.
|
||||
const inputSpec = InputSpec.of({
|
||||
operator_key: Value.text({
|
||||
name: 'Relay Operator Key',
|
||||
description:
|
||||
'Shared secret that authenticates this Recaps server to the Recap Relay. Must EXACTLY match the relay\'s "Cloud Operator Key". Generate a long random string (e.g. `openssl rand -hex 32`) and set the same value on both. Server-side only — never shown to users.',
|
||||
required: true,
|
||||
default: null,
|
||||
placeholder: 'paste the same key set on the relay',
|
||||
masked: true,
|
||||
minLength: 16,
|
||||
maxLength: 256,
|
||||
}),
|
||||
})
|
||||
|
||||
export const setRelayOperatorKey = sdk.Action.withInput(
|
||||
'set-relay-operator-key',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Relay Operator Key',
|
||||
description:
|
||||
'Set the shared operator key that lets this Recaps server vouch for its Pro/Max users to the Recap Relay by account-id (core-decoupling). Must match the key set on the relay. Multi-tenant mode only.',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: 'Multi-Tenant',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return { operator_key: config?.recap_relay_operator_key || undefined }
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
const key = (input.operator_key || '').trim()
|
||||
await configFile.merge(effects, { recap_relay_operator_key: key })
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// How often to refresh tenants' replenishable credit bucket. The
|
||||
// bucket size itself is set via "Set Default Tenant Credits" — this
|
||||
// action just controls WHEN it gets refilled.
|
||||
//
|
||||
// Refresh semantics: replenish_balance is RESET to
|
||||
// tenant_default_credits at each anniversary boundary. Any leftover
|
||||
// from the previous period is forfeit (use-it-or-lose-it). Purchased
|
||||
// credits + admin grants live in a SEPARATE bucket
|
||||
// (tenant_credits.purchased_balance) that's never wiped by refills.
|
||||
//
|
||||
// Spend order is replenish first, then purchased — so a tenant
|
||||
// burns through the refillable bucket each period before touching
|
||||
// their permanent balance.
|
||||
//
|
||||
// Anniversary alignment: refills are anchored to each tenant's
|
||||
// individual last_replenish_at timestamp (set when they signed up,
|
||||
// or when the operator first switched this action on). A user who
|
||||
// signed up at 3:17pm gets their daily refresh at 3:17pm each day,
|
||||
// not at calendar midnight.
|
||||
const inputSpec = InputSpec.of({
|
||||
period: Value.select({
|
||||
name: 'Replenishment period',
|
||||
description:
|
||||
'How often each tenant\'s replenishable credit bucket gets refilled to the configured default. Set to "off" for a one-time signup grant (Grant\'s use case — tenants are paying customers and don\'t get free daily refills). Set to daily/weekly/monthly for a free-tier-with-daily-allowance model.',
|
||||
default: 'off',
|
||||
values: {
|
||||
off: "Off (one-time signup grant only)",
|
||||
daily: 'Daily (every 24 hours)',
|
||||
weekly: 'Weekly (every 7 days)',
|
||||
monthly: 'Monthly (calendar month)',
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const setReplenishPeriod = sdk.Action.withInput(
|
||||
'set-replenish-period',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Tenant Credit Replenishment',
|
||||
description:
|
||||
"How often a tenant's free credit bucket refills (uses Set Default Tenant Credits as the refill amount). Off = no replenishment; their initial signup grant is one-time. Daily/Weekly/Monthly = anniversary-aligned refill of the replenishable bucket. Purchased credits never expire.",
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: 'Multi-Tenant',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return {
|
||||
period:
|
||||
(config?.tenant_credit_replenish_period as
|
||||
| 'off'
|
||||
| 'daily'
|
||||
| 'weekly'
|
||||
| 'monthly') || 'off',
|
||||
}
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
await configFile.merge(effects, {
|
||||
tenant_credit_replenish_period: input.period,
|
||||
})
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// When a self-hosted Recaps is running in multi-tenant mode, the
|
||||
// operator can invite family members / guests to sign up on their
|
||||
// domain. New tenants start with this many credits drawn from the
|
||||
// operator's relay credit pool (i.e., the operator pays for them).
|
||||
//
|
||||
// Default 5 is generous enough to try, tight enough that running a
|
||||
// public sign-up doesn't immediately drain the operator. Set to 0 to
|
||||
// disable auto-allocation (operator must explicitly grant credits to
|
||||
// each tenant before they can summarize).
|
||||
//
|
||||
// Doesn't apply to Grant's canonical cloud Recaps — cloud users have
|
||||
// their own keysat licenses + relay-side credit pools, so the relay's
|
||||
// per-tier quotas handle their initial credit allowance directly.
|
||||
const inputSpec = InputSpec.of({
|
||||
credits: Value.number({
|
||||
name: 'Default credits per new tenant',
|
||||
description:
|
||||
'When a new user signs up on this multi-tenant Recaps, they start with this many credits. Charged against your relay credit pool when they summarize. Set to 0 to require manual approval before any tenant can summarize.',
|
||||
required: true,
|
||||
default: 5,
|
||||
integer: true,
|
||||
min: 0,
|
||||
max: 1000,
|
||||
}),
|
||||
})
|
||||
|
||||
export const setTenantDefaultCredits = sdk.Action.withInput(
|
||||
'set-tenant-default-credits',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Default Tenant Credits',
|
||||
description:
|
||||
"How many credits new sign-ups get for free on your multi-tenant Recaps. Charged against your relay credit pool. Default: 5.",
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: 'Multi-Tenant',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return { credits: config?.tenant_default_credits ?? 5 }
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
await configFile.merge(effects, { tenant_default_credits: input.credits })
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// Anonymous-trial allowance per first-time visitor. Multi-tenant mode
|
||||
// only. When a visitor lands on the public Recaps URL without a session
|
||||
// cookie and submits a YouTube URL, the server issues them a
|
||||
// recap_anon_trial cookie with this many free summaries — no email,
|
||||
// no signup, no friction. After they're spent, the UI nudges them to
|
||||
// create an account for more credits.
|
||||
//
|
||||
// Trial summaries draw from the OPERATOR's relay credit pool, so this
|
||||
// number times the visitor volume sets the floor on your sample-cost
|
||||
// exposure. Tune downward if you see abuse, upward if you want a more
|
||||
// generous activation funnel. Set to 0 to disable trials entirely
|
||||
// (visitors immediately hit the sign-up gate).
|
||||
//
|
||||
// Defaults to 1 — enough to demo the value prop, tight enough that
|
||||
// scripted-signup abuse doesn't drain the pool fast.
|
||||
const inputSpec = InputSpec.of({
|
||||
credits: Value.number({
|
||||
name: 'Trial credits per anonymous visitor',
|
||||
description:
|
||||
'How many free summaries an unauthenticated visitor gets before being asked to sign up. Charged against your relay credit pool. Set to 0 to disable trials (immediate sign-up gate).',
|
||||
required: true,
|
||||
default: 1,
|
||||
integer: true,
|
||||
min: 0,
|
||||
max: 5,
|
||||
}),
|
||||
})
|
||||
|
||||
export const setTrialCreditsPerVisitor = sdk.Action.withInput(
|
||||
'set-trial-credits-per-visitor',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Trial Credits per Visitor',
|
||||
description:
|
||||
"How many free summaries anonymous visitors get on your multi-tenant Recaps before being prompted to sign up. Default: 1. Charged against your relay credit pool.",
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: 'Multi-Tenant',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
return { credits: config?.trial_credits_per_visitor ?? 1 }
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
await configFile.merge(effects, {
|
||||
trial_credits_per_visitor: input.credits,
|
||||
})
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configFile } from '../file-models/config.json'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// Lifetime cap on how many distinct anonymous trial cookies one IP
|
||||
// address can mint, FOR THE LIFETIME OF THE INSTALL — not a rolling
|
||||
// daily window. Was previously per-day; switched in 0.2.84 so a user
|
||||
// who clears cookies can't simply wait 24h and replay the trial.
|
||||
//
|
||||
// Anti-abuse model:
|
||||
// - Each minted cookie carries trial_credits_per_visitor credits
|
||||
// - Each IP can mint at most `trials_per_ip_lifetime` cookies, ever
|
||||
// - Combined effect: an IP's total free credits =
|
||||
// trial_credits_per_visitor × trials_per_ip_lifetime
|
||||
// - Once spent, the visitor must sign up (which grants
|
||||
// tenant_default_credits) or pay for more
|
||||
//
|
||||
// IP rotation via VPN / proxy pool defeats this, same as before. The
|
||||
// goal isn't to be unbypassable — it's to raise the floor for casual
|
||||
// scripted abuse and give the operator forensic data (IP + UA logged
|
||||
// on every trial) to manually ban any sophisticated abuser.
|
||||
//
|
||||
// Defaults to 5 — generous enough that a family on one NAT all get
|
||||
// trials, tight enough that 50 trials/IP from one address looks like
|
||||
// scripted abuse in the admin dashboard.
|
||||
//
|
||||
// Legacy field name `trials_per_ip_per_day` is preserved on the
|
||||
// config schema as a read-only alias so installs upgrading from
|
||||
// 0.2.77–0.2.83 don't lose their existing setting.
|
||||
const inputSpec = InputSpec.of({
|
||||
limit: Value.number({
|
||||
name: 'Max trial cookies per IP (lifetime)',
|
||||
description:
|
||||
'How many anonymous trial cookies can be issued from a single IP, FOR THE LIFE OF THIS INSTALL. Not a rolling daily window — once the IP hits this cap, no more trial cookies from that address ever. Higher = friendlier to shared networks (offices, families). Lower = tighter against scripted abuse + cookie-clearing replay.',
|
||||
required: true,
|
||||
default: 5,
|
||||
integer: true,
|
||||
min: 1,
|
||||
max: 50,
|
||||
}),
|
||||
})
|
||||
|
||||
export const setTrialsPerIpPerDay = sdk.Action.withInput(
|
||||
'set-trials-per-ip-per-day',
|
||||
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Trial Cookies per IP (Lifetime)',
|
||||
description:
|
||||
'Anti-abuse cap on how many trial cookies a single IP can mint over the life of this install. Default: 5. Was per-day in 0.2.77–0.2.83 and is now lifetime — see release notes for 0.2.84.',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: 'Multi-Tenant',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
|
||||
inputSpec,
|
||||
|
||||
async ({ effects }) => {
|
||||
const config = await configFile.read().once()
|
||||
// Prefer the new field; fall back to the legacy per_day key for
|
||||
// operators whose StartOS-managed config still has the old name.
|
||||
const current =
|
||||
config?.trials_per_ip_lifetime ??
|
||||
config?.trials_per_ip_per_day ??
|
||||
5
|
||||
return { limit: current }
|
||||
},
|
||||
|
||||
async ({ effects, input }) => {
|
||||
// Write to BOTH keys so any code path still reading the legacy name
|
||||
// gets a sane value too. anon-trial.js prefers the new key.
|
||||
await configFile.merge(effects, {
|
||||
trials_per_ip_lifetime: input.limit,
|
||||
trials_per_ip_per_day: input.limit,
|
||||
})
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -37,7 +37,7 @@ export const setWhisperEndpoint = sdk.Action.withInput(
|
||||
async ({ effects }) => ({
|
||||
name: 'Set Whisper Endpoint',
|
||||
description:
|
||||
'Point Recap at a self-hosted or third-party Whisper transcription server (whisper.cpp, faster-whisper-server, Groq, etc.). Free alternative to OpenAI Whisper API or Gemini multimodal transcription.',
|
||||
'Point Recaps at a self-hosted or third-party Whisper transcription server (whisper.cpp, faster-whisper-server, Groq, etc.). Free alternative to OpenAI Whisper API or Gemini multimodal transcription.',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: 'AI Providers',
|
||||
|
||||
@@ -26,5 +26,85 @@ export const configFile = FileHelper.json(
|
||||
recap_admin_password_hash: z.string().default(''),
|
||||
recap_admin_password_salt: z.string().default(''),
|
||||
recap_admin_session_secret: z.string().default(''),
|
||||
// PodcastIndex credentials — used to resolve Spotify share URLs to
|
||||
// their underlying RSS audio enclosure. Free tier signup at
|
||||
// api.podcastindex.org. Apple Podcasts URLs resolve without auth.
|
||||
podcastindex_api_key: z.string().default(''),
|
||||
podcastindex_api_secret: z.string().default(''),
|
||||
// ── Multi-tenant cloud-mode fields (added 0.2.77) ──
|
||||
// recap_mode: "single" → existing self-hosted, one operator, no auth
|
||||
// "multi" → cloud mode with email + magic-link auth,
|
||||
// per-user library, BTCPay subscriptions
|
||||
// The .s9pk defaults to single. Operators flip to multi via the
|
||||
// "Enable multi-tenant mode" StartOS action.
|
||||
recap_mode: z.enum(['single', 'multi']).default('single'),
|
||||
// Public URL used in magic-link sign-in emails. Must be the
|
||||
// ClearNet URL pointing at the Recap UI (typically a domain
|
||||
// routed through Start Tunnel). Without this set, magic-link
|
||||
// emails won't have a working sign-in link.
|
||||
recap_public_url: z.string().default(''),
|
||||
// Shared "operator key" for the core-decoupling cloud path. The secret
|
||||
// that authenticates THIS Recap server to the Recap Relay so it can
|
||||
// vouch for its signed-in users by account-id (X-Recap-User-Id) instead
|
||||
// of attaching a per-user Keysat license. Must EXACTLY match the relay's
|
||||
// own relay_cloud_operator_key. Empty = the cloud user-id path is
|
||||
// disabled and paid users fall back to the operator's relay pool.
|
||||
// Set via the "Set Relay Operator Key" StartOS action. Server-side
|
||||
// only — never sent to the browser. Picked up live by the config poll
|
||||
// (no restart needed), same as the provider API keys.
|
||||
recap_relay_operator_key: z.string().default(''),
|
||||
// Per-tenant default credit allowance — only used when running in
|
||||
// multi mode on a self-hosted operator's StartOS server. New
|
||||
// tenants (family members who sign up on the operator's domain)
|
||||
// start with this many credits. The operator's relay-credit pool
|
||||
// is debited as their tenants summarize. Doesn't apply to the
|
||||
// canonical cloud deployment because cloud users have their own
|
||||
// keysat licenses + relay-side credit pools.
|
||||
tenant_default_credits: z.number().int().nonnegative().default(5),
|
||||
// How often a tenant's "replenishable" credit bucket is refilled
|
||||
// to tenant_default_credits.
|
||||
// "off" — no refill; the signup-grant is one-time
|
||||
// "daily" — anniversary-aligned 24h
|
||||
// "weekly" — anniversary-aligned 7d
|
||||
// "monthly" — anniversary-aligned calendar month
|
||||
// Anniversary-aligned = anchored to each tenant's last_replenish_at
|
||||
// (set when they signed up or when the operator first turned the
|
||||
// period on). Purchased credits + admin grants are persisted in
|
||||
// tenant_credits.purchased_balance and NEVER affected by refills —
|
||||
// only the replenish_balance bucket gets reset to the configured N.
|
||||
tenant_credit_replenish_period: z
|
||||
.enum(["off", "daily", "weekly", "monthly"])
|
||||
.default("off"),
|
||||
// Anonymous-trial knobs (multi-mode only). Visitors who land on
|
||||
// recapapp.xyz without an account get N free summaries gated by a
|
||||
// browser cookie before being prompted to sign up. Set
|
||||
// trial_credits_per_visitor=0 to disable trials (return to an
|
||||
// auth-wall landing). trials_per_ip_per_day caps how many distinct
|
||||
// trial cookies one IP can mint in 24h — anti-script-abuse floor.
|
||||
trial_credits_per_visitor: z.number().int().nonnegative().default(1),
|
||||
// Lifetime cap on the number of distinct trial cookies one IP can
|
||||
// mint. Was previously rolling-24h; switched to lifetime so a user
|
||||
// who clears cookies can't replay the trial every day. The legacy
|
||||
// field name `trials_per_ip_per_day` is preserved below as a
|
||||
// read-only alias so installs that already have it set don't lose
|
||||
// their value during the rename — anon-trial.js prefers
|
||||
// _lifetime if set, falls back to _per_day if not.
|
||||
trials_per_ip_lifetime: z.number().int().positive().default(5),
|
||||
// Legacy alias — read for backward compatibility with v0.2.77–0.2.83
|
||||
// installs that wrote the per-day variant. Will be removed once
|
||||
// all known installs have re-saved their config under the new key.
|
||||
trials_per_ip_per_day: z.number().int().positive().default(5),
|
||||
// ── SMTP — synced from StartOS System SMTP via the SDK ──
|
||||
// main.ts subscribes to effects.getSystemSmtp() and writes these
|
||||
// fields here whenever the operator changes the system SMTP. The
|
||||
// server reads them through the same file-poll as everything else
|
||||
// and (re)builds its nodemailer transport. NEVER edit these
|
||||
// fields directly — they get overwritten on the next sync.
|
||||
smtp_host: z.string().default(''),
|
||||
smtp_port: z.number().int().default(0),
|
||||
smtp_security: z.enum(['starttls', 'tls']).default('tls'),
|
||||
smtp_username: z.string().default(''),
|
||||
smtp_password: z.string().default(''),
|
||||
smtp_from: z.string().default(''),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,74 @@
|
||||
import { i18n } from './i18n'
|
||||
import { sdk } from './sdk'
|
||||
import { uiPort } from './utils'
|
||||
import { configFile } from './file-models/config.json'
|
||||
|
||||
// ── System SMTP → config.json sync ───────────────────────────────────────
|
||||
// The StartOS SDK exposes shared SMTP credentials via effects.getSystemSmtp.
|
||||
// Passing a callback subscribes us — StartOS re-invokes the callback when
|
||||
// the operator changes their System → SMTP settings. We mirror the values
|
||||
// into our own config.json so the server (which polls the JSON via
|
||||
// server/config.js) can pick them up without needing direct SDK access.
|
||||
//
|
||||
// `password` can be null in the StartOS payload — coerce to empty string
|
||||
// so the Zod schema (z.string()) accepts it. The server treats "" the
|
||||
// same as "auth disabled".
|
||||
//
|
||||
// Only meaningful in multi-tenant cloud mode (recap_mode === 'multi'),
|
||||
// where the server sends magic-link sign-in emails. In single mode the
|
||||
// fields exist in config.json but nothing reads them.
|
||||
async function syncSystemSmtpToConfig(effects: any) {
|
||||
try {
|
||||
const smtp = await effects.getSystemSmtp({
|
||||
callback: () => {
|
||||
// Re-fire ourselves on change. Effects callbacks fire on every
|
||||
// mutation of the underlying value, so this stays in sync.
|
||||
syncSystemSmtpToConfig(effects).catch((err) => {
|
||||
console.warn('[smtp] sync callback failed:', err)
|
||||
})
|
||||
},
|
||||
})
|
||||
if (!smtp) {
|
||||
// No System SMTP configured — clear stale values so the server
|
||||
// doesn't try to send mail through a transport we no longer have
|
||||
// credentials for.
|
||||
await configFile.merge(effects, {
|
||||
smtp_host: '',
|
||||
smtp_port: 0,
|
||||
smtp_security: 'tls',
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
smtp_from: '',
|
||||
})
|
||||
return
|
||||
}
|
||||
await configFile.merge(effects, {
|
||||
smtp_host: smtp.host || '',
|
||||
smtp_port: smtp.port || 0,
|
||||
smtp_security: smtp.security || 'tls',
|
||||
smtp_username: smtp.username || '',
|
||||
smtp_password: smtp.password || '',
|
||||
smtp_from: smtp.from || '',
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[smtp] initial sync failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export const main = sdk.setupMain(async ({ effects }) => {
|
||||
console.info(i18n('Starting Recap...'))
|
||||
|
||||
// Subscribe to System SMTP changes before the daemon starts so the
|
||||
// server boots with credentials already in config.json (no race where
|
||||
// a magic-link request arrives before the first sync).
|
||||
await syncSystemSmtpToConfig(effects)
|
||||
|
||||
// Read current config to determine which mode to boot in. The
|
||||
// RECAP_MODE env var is passed to the container so the Node server
|
||||
// can branch on it without re-reading the config file at startup.
|
||||
const cfg = await configFile.read().once()
|
||||
const recapMode = cfg?.recap_mode === 'multi' ? 'multi' : 'single'
|
||||
|
||||
return sdk.Daemons.of(effects).addDaemon('primary', {
|
||||
subcontainer: await sdk.SubContainer.of(
|
||||
effects,
|
||||
@@ -23,6 +87,9 @@ export const main = sdk.setupMain(async ({ effects }) => {
|
||||
'--',
|
||||
'/usr/local/bin/docker_entrypoint.sh',
|
||||
],
|
||||
env: {
|
||||
RECAP_MODE: recapMode,
|
||||
},
|
||||
},
|
||||
ready: {
|
||||
display: i18n('Web Interface'),
|
||||
|
||||
@@ -5,7 +5,7 @@ export const short = {
|
||||
|
||||
export const long = {
|
||||
en_US:
|
||||
'Recap downloads audio from YouTube videos and podcast RSS feeds, transcribes them, ' +
|
||||
'Recaps downloads audio from YouTube videos and podcast RSS feeds, transcribes them, ' +
|
||||
'and produces structured topic-by-topic summaries with clickable timestamps. ' +
|
||||
'Pluggable AI provider system: pair any supported transcription provider with any ' +
|
||||
'analysis provider per request. Supported: Google Gemini (multimodal — transcription + ' +
|
||||
@@ -25,7 +25,7 @@ export const alertInstall = {
|
||||
en_US:
|
||||
'After installing, the fastest path is to skip the activation screen and use your free ' +
|
||||
'relay credits to summarize a few videos. ' +
|
||||
'For unlimited use: either activate a Recap license (paid features + monthly relay ' +
|
||||
'For unlimited use: either activate a Recaps license (paid features + monthly relay ' +
|
||||
'credits), or paste your own AI provider API key in Settings → API Keys & Endpoints. ' +
|
||||
'Set an admin password via the "Set Admin Password" action if you want to gate access. ' +
|
||||
'Note: The embedded YouTube player will not work if you are connected to a VPN.',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { alertInstall, long, short } from './i18n'
|
||||
|
||||
export const manifest = setupManifest({
|
||||
id: 'recap',
|
||||
title: 'Recap',
|
||||
title: 'Recaps',
|
||||
license: 'Proprietary',
|
||||
packageRepo: 'https://ten31.xyz',
|
||||
upstreamRepo: 'https://ten31.xyz',
|
||||
@@ -39,7 +39,7 @@ export const manifest = setupManifest({
|
||||
// (cloud providers stay available).
|
||||
ollama: {
|
||||
description:
|
||||
'Run local LLMs (Llama, Mistral, etc.) for topic analysis without a cloud API. Recap auto-detects the install and pre-fills its connection URL.',
|
||||
'Run local LLMs (Llama, Mistral, etc.) for topic analysis without a cloud API. Recaps auto-detects the install and pre-fills its connection URL.',
|
||||
optional: true,
|
||||
s9pk: null,
|
||||
},
|
||||
|
||||
+110
-2
@@ -66,8 +66,116 @@ import { v_0_2_44 } from './v0.2.44'
|
||||
import { v_0_2_45 } from './v0.2.45'
|
||||
import { v_0_2_46 } from './v0.2.46'
|
||||
import { v_0_2_47 } from './v0.2.47'
|
||||
import { v_0_2_48 } from './v0.2.48'
|
||||
import { v_0_2_49 } from './v0.2.49'
|
||||
import { v_0_2_50 } from './v0.2.50'
|
||||
import { v_0_2_51 } from './v0.2.51'
|
||||
import { v_0_2_52 } from './v0.2.52'
|
||||
import { v_0_2_53 } from './v0.2.53'
|
||||
import { v_0_2_54 } from './v0.2.54'
|
||||
import { v_0_2_55 } from './v0.2.55'
|
||||
import { v_0_2_56 } from './v0.2.56'
|
||||
import { v_0_2_57 } from './v0.2.57'
|
||||
import { v_0_2_58 } from './v0.2.58'
|
||||
import { v_0_2_59 } from './v0.2.59'
|
||||
import { v_0_2_60 } from './v0.2.60'
|
||||
import { v_0_2_61 } from './v0.2.61'
|
||||
import { v_0_2_62 } from './v0.2.62'
|
||||
import { v_0_2_63 } from './v0.2.63'
|
||||
import { v_0_2_64 } from './v0.2.64'
|
||||
import { v_0_2_65 } from './v0.2.65'
|
||||
import { v_0_2_66 } from './v0.2.66'
|
||||
import { v_0_2_67 } from './v0.2.67'
|
||||
import { v_0_2_68 } from './v0.2.68'
|
||||
import { v_0_2_69 } from './v0.2.69'
|
||||
import { v_0_2_70 } from './v0.2.70'
|
||||
import { v_0_2_71 } from './v0.2.71'
|
||||
import { v_0_2_72 } from './v0.2.72'
|
||||
import { v_0_2_73 } from './v0.2.73'
|
||||
import { v_0_2_74 } from './v0.2.74'
|
||||
import { v_0_2_75 } from './v0.2.75'
|
||||
import { v_0_2_76 } from './v0.2.76'
|
||||
import { v_0_2_77 } from './v0.2.77'
|
||||
import { v_0_2_78 } from './v0.2.78'
|
||||
import { v_0_2_79 } from './v0.2.79'
|
||||
import { v_0_2_80 } from './v0.2.80'
|
||||
import { v_0_2_81 } from './v0.2.81'
|
||||
import { v_0_2_82 } from './v0.2.82'
|
||||
import { v_0_2_83 } from './v0.2.83'
|
||||
import { v_0_2_84 } from './v0.2.84'
|
||||
import { v_0_2_85 } from './v0.2.85'
|
||||
import { v_0_2_86 } from './v0.2.86'
|
||||
import { v_0_2_87 } from './v0.2.87'
|
||||
import { v_0_2_88 } from './v0.2.88'
|
||||
import { v_0_2_89 } from './v0.2.89'
|
||||
import { v_0_2_90 } from './v0.2.90'
|
||||
import { v_0_2_91 } from './v0.2.91'
|
||||
import { v_0_2_92 } from './v0.2.92'
|
||||
import { v_0_2_93 } from './v0.2.93'
|
||||
import { v_0_2_94 } from './v0.2.94'
|
||||
import { v_0_2_95 } from './v0.2.95'
|
||||
import { v_0_2_96 } from './v0.2.96'
|
||||
import { v_0_2_97 } from './v0.2.97'
|
||||
import { v_0_2_98 } from './v0.2.98'
|
||||
import { v_0_2_99 } from './v0.2.99'
|
||||
import { v_0_2_100 } from './v0.2.100'
|
||||
import { v_0_2_101 } from './v0.2.101'
|
||||
import { v_0_2_102 } from './v0.2.102'
|
||||
import { v_0_2_103 } from './v0.2.103'
|
||||
import { v_0_2_104 } from './v0.2.104'
|
||||
import { v_0_2_105 } from './v0.2.105'
|
||||
import { v_0_2_106 } from './v0.2.106'
|
||||
import { v_0_2_107 } from './v0.2.107'
|
||||
import { v_0_2_108 } from './v0.2.108'
|
||||
import { v_0_2_109 } from './v0.2.109'
|
||||
import { v_0_2_110 } from './v0.2.110'
|
||||
import { v_0_2_111 } from './v0.2.111'
|
||||
import { v_0_2_112 } from './v0.2.112'
|
||||
import { v_0_2_113 } from './v0.2.113'
|
||||
import { v_0_2_114 } from './v0.2.114'
|
||||
import { v_0_2_115 } from './v0.2.115'
|
||||
import { v_0_2_116 } from './v0.2.116'
|
||||
import { v_0_2_117 } from './v0.2.117'
|
||||
import { v_0_2_118 } from './v0.2.118'
|
||||
import { v_0_2_119 } from './v0.2.119'
|
||||
import { v_0_2_120 } from './v0.2.120'
|
||||
import { v_0_2_121 } from './v0.2.121'
|
||||
import { v_0_2_122 } from './v0.2.122'
|
||||
import { v_0_2_123 } from './v0.2.123'
|
||||
import { v_0_2_124 } from './v0.2.124'
|
||||
import { v_0_2_125 } from './v0.2.125'
|
||||
import { v_0_2_126 } from './v0.2.126'
|
||||
import { v_0_2_127 } from './v0.2.127'
|
||||
import { v_0_2_128 } from './v0.2.128'
|
||||
import { v_0_2_129 } from './v0.2.129'
|
||||
import { v_0_2_130 } from './v0.2.130'
|
||||
import { v_0_2_131 } from './v0.2.131'
|
||||
import { v_0_2_132 } from './v0.2.132'
|
||||
import { v_0_2_133 } from './v0.2.133'
|
||||
import { v_0_2_134 } from './v0.2.134'
|
||||
import { v_0_2_135 } from './v0.2.135'
|
||||
import { v_0_2_136 } from './v0.2.136'
|
||||
import { v_0_2_137 } from './v0.2.137'
|
||||
import { v_0_2_138 } from './v0.2.138'
|
||||
import { v_0_2_139 } from './v0.2.139'
|
||||
import { v_0_2_140 } from './v0.2.140'
|
||||
import { v_0_2_141 } from './v0.2.141'
|
||||
import { v_0_2_142 } from './v0.2.142'
|
||||
import { v_0_2_143 } from './v0.2.143'
|
||||
import { v_0_2_144 } from './v0.2.144'
|
||||
import { v_0_2_145 } from './v0.2.145'
|
||||
import { v_0_2_146 } from './v0.2.146'
|
||||
import { v_0_2_147 } from './v0.2.147'
|
||||
import { v_0_2_148 } from './v0.2.148'
|
||||
import { v_0_2_149 } from './v0.2.149'
|
||||
import { v_0_2_150 } from './v0.2.150'
|
||||
import { v_0_2_151 } from './v0.2.151'
|
||||
import { v_0_2_152 } from './v0.2.152'
|
||||
import { v_0_2_153 } from './v0.2.153'
|
||||
import { v_0_2_154 } from './v0.2.154'
|
||||
import { v_0_2_155 } from './v0.2.155'
|
||||
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_0_2_47,
|
||||
other: [v_0_2_46, v_0_2_45, v_0_2_44, v_0_2_43, v_0_2_42, v_0_2_41, v_0_2_40, v_0_2_39, v_0_2_38, v_0_2_37, v_0_2_36, v_0_2_35, v_0_2_34, v_0_2_33, v_0_2_32, v_0_2_31, v_0_2_30, v_0_2_29, v_0_2_28, v_0_2_27, v_0_2_26, v_0_2_25, v_0_2_24, v_0_2_23, v_0_2_22, v_0_2_21, v_0_2_20, v_0_2_19, v_0_2_18, v_0_2_17, v_0_2_16, v_0_2_15, v_0_2_14, v_0_2_13, v_0_2_12, v_0_2_11, v_0_2_10, v_0_2_9, v_0_2_8, v_0_2_7, v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_18, v_0_1_17, v_0_1_16, v_0_1_15, v_0_1_14, v_0_1_13, v_0_1_12, v_0_1_11, v_0_1_10, v_0_1_9, v_0_1_8, v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0],
|
||||
current: v_0_2_155,
|
||||
other: [v_0_2_154, v_0_2_153, v_0_2_152, v_0_2_151, v_0_2_150, v_0_2_149, v_0_2_148, v_0_2_147, v_0_2_146, v_0_2_145, v_0_2_144, v_0_2_143, v_0_2_142, v_0_2_141, v_0_2_140, v_0_2_139, v_0_2_138, v_0_2_137, v_0_2_136, v_0_2_135, v_0_2_134, v_0_2_133, v_0_2_132, v_0_2_131, v_0_2_130, v_0_2_129, v_0_2_128, v_0_2_127, v_0_2_126, v_0_2_125, v_0_2_124, v_0_2_123, v_0_2_122, v_0_2_121, v_0_2_120, v_0_2_119, v_0_2_118, v_0_2_117, v_0_2_116, v_0_2_115, v_0_2_114, v_0_2_113, v_0_2_112, v_0_2_111, v_0_2_110, v_0_2_109, v_0_2_108, v_0_2_107, v_0_2_106, v_0_2_105, v_0_2_104, v_0_2_103, v_0_2_102, v_0_2_101, v_0_2_100, v_0_2_99, v_0_2_98, v_0_2_97, v_0_2_96, v_0_2_95, v_0_2_94, v_0_2_93, v_0_2_92, v_0_2_91, v_0_2_90, v_0_2_89, v_0_2_88, v_0_2_87, v_0_2_86, v_0_2_85, v_0_2_84, v_0_2_83, v_0_2_82, v_0_2_81, v_0_2_80, v_0_2_79, v_0_2_78, v_0_2_77, v_0_2_76, v_0_2_75, v_0_2_74, v_0_2_73, v_0_2_72, v_0_2_71, v_0_2_70, v_0_2_69, v_0_2_68, v_0_2_67, v_0_2_66, v_0_2_65, v_0_2_64, v_0_2_63, v_0_2_62, v_0_2_61, v_0_2_60, v_0_2_59, v_0_2_58, v_0_2_57, v_0_2_56, v_0_2_55, v_0_2_54, v_0_2_53, v_0_2_52, v_0_2_51, v_0_2_50, v_0_2_49, v_0_2_48, v_0_2_47, v_0_2_46, v_0_2_45, v_0_2_44, v_0_2_43, v_0_2_42, v_0_2_41, v_0_2_40, v_0_2_39, v_0_2_38, v_0_2_37, v_0_2_36, v_0_2_35, v_0_2_34, v_0_2_33, v_0_2_32, v_0_2_31, v_0_2_30, v_0_2_29, v_0_2_28, v_0_2_27, v_0_2_26, v_0_2_25, v_0_2_24, v_0_2_23, v_0_2_22, v_0_2_21, v_0_2_20, v_0_2_19, v_0_2_18, v_0_2_17, v_0_2_16, v_0_2_15, v_0_2_14, v_0_2_13, v_0_2_12, v_0_2_11, v_0_2_10, v_0_2_9, v_0_2_8, v_0_2_7, v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_18, v_0_1_17, v_0_1_16, v_0_1_15, v_0_1_14, v_0_1_13, v_0_1_12, v_0_1_11, v_0_1_10, v_0_1_9, v_0_1_8, v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_100 = VersionInfo.of({
|
||||
version: '0.2.100:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Revert the anon-buyer recovery-email field added in v0.2.99. Asking an anon visitor for an email at the credit-purchase step defeats the value-prop of anon credit purchase. The mobile-menu Sign up entry (also added in v0.2.99) stays in place. The cross-cookie-jar edge case (e.g., Superhuman opening the magic link in DuckDuckGo while the credits were bought in Safari) is now a documented known limitation — sign up first if you want credits portable across browsers.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_101 = VersionInfo.of({
|
||||
version: '0.2.101:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Mobile top-bar fixes. (1) The Summarize / Queue / Subscribe pill is now a square right-arrow icon button on phones — same size as the hamburger menu next to it — instead of a wide purple pill that overlapped the menu. Desktop keeps the labeled pill. (2) Fix the pizza-tracker progress breadcrumb (Downloading → Transcribing → Analyzing → Done) not appearing on mobile Safari: it was nested inside the flex-wrap + position:sticky .top-bar where iOS Safari was eating it. The mobile copy now renders as a sibling below .top-bar, completely outside the flex container, so it shows up reliably during summarization. Desktop is unchanged.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_102 = VersionInfo.of({
|
||||
version: '0.2.102:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Two iOS Safari buy-credits fixes. (1) The 'Couldn't open purchase. Load failed' error on first modal open: iOS Safari sometimes silently aborts the initial fetch from a cold tab. The buy-credits modal now silently retries once with a 600ms backoff before surfacing the error, hiding the flake. The relay /packages timeout also bumped from 5s to 10s so a slow cold relay request from cellular doesn't trip the abort. (2) The 'Sign up for a free account first — we need to know where to credit your purchase' error when an anon buyer clicks a package: this fired whenever the visitor's IP had hit the lifetime trial-cookie cap (default 5). The cap exists to prevent abuse of FREE credits — a paying buyer is not abuse. /api/credits/buy now forces a fresh trial cookie mint regardless of the IP cap and regardless of whether trials are disabled, so a paying buyer always has a buyer_id to credit the settle to.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_103 = VersionInfo.of({
|
||||
version: '0.2.103:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Self-service recovery for anon credit purchases that didn't transfer at signup. When a Safari Private mode visitor buys credits, the trial cookie tracking the purchase often doesn't survive the magic-link click (the email may open in a different browser context — non-private tab, in-app webview, or a different private window — each with its own cookie jar). linkToUser then runs blind and the credits stay orphaned in anon_trials. New: (1) the buy-credits modal now surfaces the BTCPay invoice ID prominently for anon buyers with a one-tap copy button and a note explaining when they'd need it. (2) Account settings has a new 'Claim a previous purchase' section: paste the invoice ID, the server verifies the invoice is settled at the relay AND was an anon-buyer purchase that's still unapplied, then credits the signed-in user. Idempotent. Only anon-buyer rows are claimable so signed-in user purchases can't be hijacked.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_104 = VersionInfo.of({
|
||||
version: '0.2.104:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Magic-link signup now reliably transfers anon credits even across cookie jars. The anon trial cookie is now captured server-side at /auth/request-link time and stored alongside the magic-link token in a new magic_link_tokens.trial_cookie_id column (added in-place on existing installs via migration). At /auth/verify the linker reads the trial cookie ID directly from the token row instead of from the verify request's cookies — so it doesn't matter if the magic-link click lands in Safari Private mode, an in-app email webview, or a different browser entirely. The cookie ID is never put in the URL (would leak to anyone who saw the email); only the random token is. The req.cookies path is kept as a fallback for old tokens from before this column existed and for any edge case where request-link didn't capture it. The manual 'Claim a previous purchase' UI from v0.2.103 stays as a belt-and-suspenders for users who clear cookies between request-link and verify, or whose purchase predates their signup intent entirely.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_105 = VersionInfo.of({
|
||||
version: '0.2.105:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Two mobile polish items. (1) Silent retry on the magic-link send button. iOS Safari sometimes aborts the very first fetch from a cold tab with a generic 'Load failed' TypeError; the user previously had to manually click again. The Free signup path in the tier modal, the Pro/Max license-purchase path, and the standalone /auth.html magic-link form all now retry once silently with a ~500ms backoff. Server errors (4xx/5xx) are NOT retried — those are deliberate responses, not transport flakes. (2) Pro / Max upgrade entries in the mobile hamburger menu for signed-in free users. Previously the menu only exposed 'Buy more credits' which made plan upgrades a multi-tap drilldown through settings. Now signed-in users who aren't already on Pro or Max see 'Upgrade to Pro' and 'Upgrade to Max' below their credit line. Each entry opens the in-app license purchase modal with the relevant tier pre-selected, jumping straight to the discount-entry step.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_106 = VersionInfo.of({
|
||||
version: '0.2.106:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Tighten the mobile menu upgrade UX. v0.2.105 added two upgrade entries (Pro + Max) which forced free users to pick between tiers without seeing the comparison. Now Free signed-in users see a single 'Upgrade' entry that opens the buy modal at the tier picker so they can compare Pro vs Max side-by-side. Pro users see a single 'Upgrade to Max' entry that opens the modal pre-selected on Max. Max users see no upgrade entry. Anon/trial visitors continue through the existing Sign up → 3-tier modal flow.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_107 = VersionInfo.of({
|
||||
version: '0.2.107:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Cleaner sign-in form. Removed the 'you@example.com' placeholder from the email field on both /auth.html and the in-app tier signup modal (it was clutter that some users read as a real suggested value). Removed the 'Leave blank to receive a sign-in link' placeholder from the password field on /auth.html — the same guidance already appears in the helper paragraph below the submit button, so the placeholder was duplicative.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_108 = VersionInfo.of({
|
||||
version: '0.2.108:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Settings panel CTA fixes for tenants. (1) The 'Upgrade to Pro' pill on the Plan section now reads 'Upgrade' for free signed-in users and trial visitors — clicking opens the buy modal showing Pro and Max side-by-side, so labeling the pill 'Upgrade to Pro' was misleading (you're not committing to Pro by clicking, you're opening a comparison). Pro users (not Max) see 'Upgrade to Max' since the destination is unambiguous — clicking pre-selects the Max tier in the buy modal. (2) The 'Sign up' button on the Account section (anon trial + not-signed-in states) now opens the 3-tier signup modal (Free / Pro / Max) instead of dropping the visitor on the magic-link form. Same modal as the toolbar 'Sign up' pill — visitors get the full pricing menu before committing to a tier. Both fixes apply on mobile and web (the settings panel renders the same on both)."
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_109 = VersionInfo.of({
|
||||
version: '0.2.109:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Rebrand the public-facing name from 'Recap' to 'Recaps' (matching the new recaps.cc domain). Updated: the StartOS package title, manifest short/long descriptions and install alert; every web UI heading, modal title, and body string that referred to the brand; every StartOS action display name and description; the magic-link sign-in email and the post-purchase 'your account is ready' email subjects + bodies; the auth error page; the 503-when-misconfigured server error messages. Kept as-is on purpose: (1) 'Recap credits' as the credit-unit terminology — 'a recap' is the unit, 'Recaps' is the brand. (2) All internal identifiers: StartOS package id (recap), config field names (recap_license_key), env vars (RECAP_MODE), cookie names (recap_session, recap_anon_trial), database file names, X-Recap-Install-Id / X-Recap-Job-Id wire headers, and the recap-relay backend project name. Changing any of those would break existing installs' upgrade path or the relay protocol.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_110 = VersionInfo.of({
|
||||
version: '0.2.110:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Phase 1 of the inline-payment migration plus several UX cleanups. (1) Sign-in form: the optional password field is now hidden by default; a 'Use password instead' link below the submit button reveals it on demand. Magic-link signup is the dominant path and the field was just clutter. The form auto-detects the field state so a browser autofill into the hidden field can't accidentally force password mode. (2) Default relay base URL updated from relay.keysat.xyz to relay.recaps.cc. Update your StartOS network config (recap_public_url etc.) accordingly if needed. (3) Buy-credits modal: removed the 'Purchased credits never expire' explanatory paragraph (already covered by the anon-buyer recovery message). Buy button now reads '⚡ Pay with Lightning' instead of 'Buy →'. OG/Twitter URLs in the HTML head updated from recapapp.xyz to recaps.cc. (4) Inline Lightning invoice UI: when the relay's /relay/credits/buy response includes a bolt11 field, Recaps now renders an inline QR + copyable BOLT11 + 'Open in wallet' deep link instead of opening BTCPay in a new tab. Polling continues to drive the settle handoff. Falls back cleanly to the legacy external-tab flow if bolt11 isn't present, so existing relay versions keep working. QR encoder is vendored locally (/assets/qrcode.min.js) so it renders behind start-tunnel without external internet. Next: the relay needs to surface bolt11 + lightning_expires_at in its buy response envelope to activate the inline path.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_111 = VersionInfo.of({
|
||||
version: '0.2.111:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Phase 1 diagnostics. Adds two visibility aids to help confirm the inline-Lightning path is active end-to-end. (1) Console.log on every /api/credits/buy response — prints the raw envelope plus a one-line 'bolt11 present: yes/NO' marker so you can confirm at a glance whether the relay is surfacing the new field. (2) Admin-only diagnostic line on the legacy-fallback polling view that explicitly says 'Inline payment unavailable — relay didn't return a BOLT11' with the version requirement (relay v0.2.71+). Both are admin/operator-only — regular tenants don't see the diagnostic line. Will be removed once we've confirmed the inline path works for every BTCPay store configuration.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_112 = VersionInfo.of({
|
||||
version: '0.2.112:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Make the Phase 1 'inline payment unavailable' diagnostic visible to all viewers (not just admin) while we trace why the new bolt11 path isn't lighting up on Grant's test rig. Tiny italic gray line at the bottom of the legacy-fallback polling view, says what needs to be fixed (relay version OR BTCPay API key scope). Will be removed once inline rendering is confirmed working everywhere.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_113 = VersionInfo.of({
|
||||
version: '0.2.113:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Surface the relay's new `_ln_debug` field in the buy-credits modal's diagnostic line so operators can see exactly why the inline-Lightning path isn't lighting up (no Lightning method on BTCPay response vs fetch failed with HTTP status) without tailing relay logs. Requires relay v0.2.72+ for the diagnostic to be populated; falls through to a friendly 'relay didn't return BOLT11' message on older relays.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_114 = VersionInfo.of({
|
||||
version: '0.2.114:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Inline Lightning UI polish. (1) Fix the missing QR render: the previous attempt used scalable:true which strips the SVG's intrinsic dimensions, and the parent inline-block had no fixed size, so the SVG collapsed to 0×0. Switched to a fixed-size SVG at cellSize:3 — a typical BOLT11 lands around 225px square, predictable across browsers and zoom levels. (2) Tightened the inline-payment layout: cap modal width to 420px in the polling state (was 1000px — the tier picker still uses the wide layout), header label flips to 'Pay with Lightning', BOLT11 invoice renders on a single truncated line with ellipsis instead of multi-line wrap, smaller QR + padding, more compact button + helper text.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_115 = VersionInfo.of({
|
||||
version: '0.2.115:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Better payment-confirmed moment for credit purchases. Instead of immediately closing the buy modal and tossing a 'credits added' toast in the corner, the modal now transitions to a centered success view: a green pop-in checkmark, a brief radial sparkle burst (✨ ⚡ ⭐ pure CSS, no library), 'Payment confirmed' headline, and 'N Recap credits added to your balance' subtext. Auto-closes after 2.8s; Done button closes sooner. Gives the buyer a clear visual beat that the payment actually landed before the UI moves on.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_116 = VersionInfo.of({
|
||||
version: '0.2.116:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Phase 1 housekeeping: remove the dev-time 'Inline payment unavailable' diagnostic line + the buy-response console.log now that the inline-Lightning path is verified working end-to-end. The legacy external-tab fallback (used when the relay can't extract a BOLT11 from BTCPay) stays in place as a quiet safety net — it just no longer announces itself as a debug breadcrumb.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_117 = VersionInfo.of({
|
||||
version: '0.2.117:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Fix credits-purchased-by-Pro-tenant going to the wrong pool. The relayHeaders() helper used by /api/credits/buy and the credit poll/sweep paths was unconditionally sending the operator's install ID + license key, regardless of whether the buyer was a Pro/Max signed-in tenant with their own license. Result: a Pro tenant's BTCPay invoice got stashed with the operator's license_fingerprint, the relay's BTCPay webhook credited the operator's license-keyed pool, and the tenant's own balance never moved. Now relayHeaders() routes by per-request identity: signed-in user with a license → use THEIR install ID + license; anon / free / single-mode → fall back to operator identity (unchanged behavior for those cases). Threaded `req` through every relay caller in credits-purchase.js including the sweepUnappliedPurchases helper.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_118 = VersionInfo.of({
|
||||
version: '0.2.118:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Phase-1-bugfix diagnostic. v0.2.117's relayHeaders() fix should have routed credit purchases to a Pro tenant's own license-keyed pool, but Grant's first test still showed the balance stuck. Two visibility aids ship in this version: (1) /api/credits/buy now logs the outbound identity (install ID + license prefix/suffix + which branch fired — per_user vs operator_fallback) so it's obvious from a single relay-log line whether the fix engaged. (2) New GET /api/credits/diagnose endpoint (signed-in only) reports the exact identity Recaps would send to the relay for this user, the user's recent pending_purchases rows, and the relay's response when polling the latest invoice — lets us figure out fingerprint vs webhook vs pool-keying problems in one round-trip without log tailing.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_119 = VersionInfo.of({
|
||||
version: '0.2.119:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Remove Phase-1-bugfix diagnostics now that buy → BTCPay → webhook → relay pool → display flow is verified end-to-end. Drops the /api/credits/diagnose endpoint and the outbound-identity log line from /api/credits/buy. The actual fix from v0.2.117 (per-user identity routing in relayHeaders) stays; this is just cleanup. Root cause of the Pro-tenant credit-not-incrementing bug turned out to be two-part: (1) Recaps was sending the operator's identity instead of the tenant's — fixed in v0.2.117; (2) the BTCPay webhook URL was still pointed at the old relay tunnel (relay.keysat.xyz) instead of the new one (relay.recaps.cc) — operator fixed via the relay's Set BTCPay Connection action.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_120 = VersionInfo.of({
|
||||
version: '0.2.120:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Fix the admin Tenants list overflow in Settings → Tenants. Each row's email + metadata + action buttons were all sharing a single flex row that didn't fit cleanly inside the 480px settings modal — the +Credits / Sign out / Delete buttons got clipped at the right edge. Buttons now stack on their own row below the metadata, right-aligned and wrap-friendly so they're readable at any modal width. Same fix applies on mobile.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_121 = VersionInfo.of({
|
||||
version: '0.2.121:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Remove the 'N segments' counter from the results header, stats bar, video metadata line, and podcast metadata line. The raw transcript-segment count (often 600+) was a noisy implementation detail that didn't tell viewers anything useful — knowing a 90-minute video has 637 segments doesn't help anyone decide whether to read it. The header now reads simply 'N topics · M:SS total' across all variants (video, podcast, and the live-streaming header counter). Topic count and total duration are the actual viewer-facing numbers worth surfacing.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_122 = VersionInfo.of({
|
||||
version: '0.2.122:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Phase 1E: speaker diarization rendering. When the operator's relay is on v0.2.88+ AND has diarization enabled, summarized videos now show a 'Speakers' legend above the topic list and a colored chip beside each transcript line — Speaker A in red, B in blue, C in green, D in yellow, etc. (8 distinct colors cycle for >8 speakers, which is rare in interview/podcast content). Chip color is stable per global speaker ID so the same voice always gets the same chip throughout the transcript. Confidence < 50% adds a trailing '?' to the chip so you know the assignment is uncertain. Legend shows total turns + total speaking time per speaker. Per-entry speakers are attached server-side via time-matching: the relay's fine-grained Parakeet segments (with diarization labels) are joined to Recap's merged readable lines by start-time intersection. Backwards-compatible: older relays without transcript_segments simply skip the rendering — UI looks identical to v0.2.121 for those sessions. Persisted in history alongside the existing transcript/analysis JSON so reopening a saved session restores the speaker labels.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_123 = VersionInfo.of({
|
||||
version: '0.2.123:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Drop the per-speaker turn count from the Speakers legend. A 90-min interview with 1000+ turns per speaker was emitting numbers too big to mean anything to viewers; speaking time (24:42, 65:37, 3s) is the actually-useful per-speaker fact and lets a glance distinguish 'main host' from 'brief interlude'. Same legend layout, just the noisy field removed.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_124 = VersionInfo.of({
|
||||
version: '0.2.124:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Frontend half of the pipelined-analyze work that ships in relay v0.2.89. Browser handler for transcript_ready no longer wipes state.chunks unless the videoId changed — this preserves partial sections that arrived earlier via sections_partial in pipelined mode. The relay-mode branch in server/index.js now emits transcript_ready EARLY (from the first onWindowComplete or from onTranscribeComplete, whichever fires first) so the browser flips from loading view to results view in time to render the streaming partial sections. SDK provider/relay.js forwards a new `windowEntries` field on the window_complete callback — in pipelined mode each window arrives with its own bracketed entries embedded; sequential mode keeps falling back to the global streamedRelayEntries cache. Net effect: when summarizing a 94-min video through a v0.2.89+ relay, the first topics now render at T~80s instead of T~160s — the loading-screen → results-view transition + first chunks landing happen DURING transcribe instead of after it. Older relays keep working — pipelined fields are absent and the existing sequential code path takes over.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_125 = VersionInfo.of({
|
||||
version: '0.2.125:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Tiny copy fix: the streaming-indicator below the topic list said 'N/M sections ready' which read confusingly against the 'topics · segments' nomenclature elsewhere in the UI ('sections' could be misread as 'segments'). Now says 'N/M windows ready' — matches the relay's internal name for analyze windows.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_126 = VersionInfo.of({
|
||||
version: '0.2.126:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Tone down the streaming-indicator below the topics list. The 'Analyzing window N / M…' line was rendering with NO style — it inherited the browser default font-size which read way bigger and more prominent than the surrounding sidebar text. Now styled to match the dimmer treatment the podcast-variant indicator already uses: 11px italic, slate-grey at 85% opacity, smaller pulsing dot. Reads as subtle progress metadata instead of a big call-out.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_127 = VersionInfo.of({
|
||||
version: '0.2.127:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Aggressive tone-down of the streaming 'Analyzing window N / M…' indicator that was still reading too prominently after v0.2.126. Drops to 10px (down from 11px) at 70% opacity, muted slate color, tighter padding, !important to defeat any cached or inherited CSS. Matching treatment on the podcast-variant indicator below the topic list. Hard-refresh after install if it still looks the same size — that means the browser is rendering the old cached script.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_128 = VersionInfo.of({
|
||||
version: '0.2.128:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Frontend half of the Phase 2 post-cluster polish work that ships in relay v0.2.95. The speakers legend now shows the inferred real name when the relay's polish pass identified one: '[A] Matt Hill · 24:42' instead of '[A] Speaker A · 24:42'. Chip letters and colors stay the same (so the visual identity is stable as you scroll the transcript). When the LLM couldn't confidently name a speaker, the legend falls back to the cluster ID. Per-line chip tooltips also show the inferred name ('Matt Hill (Speaker_A) · conf 87%'). Topic summaries arrive already polished from the relay — they attribute statements to specific speakers ('Matt Hill explains why self-hosting matters' instead of 'the discussion centers around self-hosting'). SDK provider/relay.js forwards the new speaker_names field; recap-server saves it to history alongside speakers, so reopening saved sessions restores names.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_129 = VersionInfo.of({
|
||||
version: '0.2.129:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Speaker chips on transcript lines now show INITIALS when the relay's post-cluster polish has identified a real name: 'Matt Hill' → 'MH', 'Brandon Carpalis' → 'BC', single-name 'Alice' → 'A'. Unidentified speakers (polish returned null) keep their cluster letter ('A'/'B'/'C') so the chip stays legible either way. Tooltip on hover still shows the full name. Chip min-width nudged from 22px to 26px so 2-letter initials breathe. Same initials show in the Speakers legend so it matches the chips throughout the transcript. Also brightened the 'Analyzing window N/M' streaming indicator — was 10px at 70% opacity in muted slate (per Grant's feedback, barely visible); now 12px at 95% opacity in #94a3b8 grey, more comfortable to read but still subordinate to the topic content.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_130 = VersionInfo.of({
|
||||
version: '0.2.130:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Hide '0 topics, 0:00 total' header counts during streaming. Three placements (podcast meta line, video meta line, stats-bar in the results-right pane) all now show the counts only AFTER the result event fires and state.streaming flips false — so the user no longer sees a placeholder '0 topics' display while analyze windows are still landing. No backend change; pure render-gating. Browser cache note: if Recap shows the old behavior after install, hard-refresh (Cmd+Shift+R) to force a fresh fetch of the static HTML.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_131 = VersionInfo.of({
|
||||
version: '0.2.131:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Frontend half of relay v0.2.100's small-cluster suppression. Two new chip states: (1) UNCERTAIN — the post-cluster suppression pass reassigned a small cluster to one of the main anchor speakers as best-guess attribution. Chip renders with a '?' suffix (e.g. 'MH?') and the tooltip notes 'best-guess attribution'. (2) UNKNOWN — the special Speaker_Unknown pseudo-speaker grouping brief utterances that didn't confidently match any main anchor. Chip is grey ('?'), legend reads 'Unknown', and it sorts to the end of the legend after named speakers. The chip color and class for any given speaker stays stable as before — visual identity is preserved across the transcript. Existing low-confidence rendering (per-segment diarize confidence < 0.5) continues to work; uncertain + low-conf now BOTH trigger the '?' suffix.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user