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:
Keysat
2026-06-13 14:25:05 -05:00
parent db580abad7
commit 0ae59f3550
176 changed files with 23823 additions and 803 deletions
+64
View File
@@ -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
},
)
+20
View File
@@ -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)
+1 -1
View File
@@ -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',
+4 -4
View File
@@ -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',
+1 -1
View File
@@ -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',
+59
View File
@@ -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
},
)
+59
View File
@@ -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
},
)
+59
View File
@@ -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
},
)
+73
View File
@@ -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
},
)
+79
View File
@@ -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.770.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.770.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
},
)
+1 -1
View File
@@ -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',