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',
+80
View File
@@ -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.770.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(''),
}),
)
+67
View File
@@ -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'),
+2 -2
View File
@@ -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.',
+2 -2
View File
@@ -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
View File
@@ -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],
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -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 }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_132 = VersionInfo.of({
version: '0.2.132:0',
releaseNotes: {
en_US:
"Export — three changes. (1) PDF export now includes speaker chips. Each transcript line is prefixed with a small colored rounded-rectangle chip showing the speaker's initials (or cluster letter when no name was inferred), matching the on-screen palette so the same person is the same color in the dashboard, on screen, and in the printed PDF. The PDF also gets a 'SPEAKERS' legend row above the topics list with each speaker's chip + full name + total speaking time, so a reader looking at the PDF cold has a key. Skips entirely when the session had no diarization (older saves, diarization disabled, etc.) so non-diarized PDFs render exactly as before. (2) New Markdown export. Renders a human-readable .md doc with the title, topic count, source link (YouTube), a Speakers section, and per-topic ## headings (with timestamps and YouTube deeplinks) followed by speaker-attributed transcript lines inside an expandable <details> block — the same format the relay's internal-meetings markdown export uses, so a meeting recap and a Recaps export read the same in a markdown viewer. (3) New JSON export. Downloads the raw saved session record verbatim — chunks + entries + speakers + speakerNames + logs — useful for piping into other tools, archiving outside the app, or feeding into an LLM as context. (4) The single 'Export PDF' button on the main view and the per-row icon in the history sidebar now open an 'Export ▾' menu with all three options (PDF / Markdown / JSON). Same component on desktop and mobile — taps to the same 36px-min-height rows that fit a thumb. Closes on outside click or Escape.",
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_133 = VersionInfo.of({
version: '0.2.133:0',
releaseNotes: {
en_US:
"Anonymous-trial UX fix. Before: an anonymous visitor on a multi-tenant Recap whose server-side trial allowance had been spent (whether by depleted cookie credits OR by hitting the operator's per-IP cookie-mint cap) would still see 'N free credits ready' in the toolbar — the pill came from the static operator config rather than a real eligibility check. They'd paste a URL, the player + 'Processing…' status would render optimistically, and only AFTER the request hit the server would a generic 'Processing failed — Try again' banner appear (with the player still showing). Three changes. (1) /api/account/whoami now actually checks whether the visitor's server-side identifier (matched on the implementation side) can issue a fresh trial cookie; when the answer is no, returns available_trial_credits: 0 so the toolbar pill flips to a clickable 'Out of free credits' chip + 'Buy credits' button. (2) processUrl() pre-flight gate refuses to start the optimistic flow when the visitor has 0 credits to spend — applies to BOTH anonymous-and-blocked and trial-cookie-with-zero-balance — no player render, no 'Processing…' status, just a clean modal with Sign up / Buy credits / Sign in CTAs. (3) Race fallback: if the trial budget is exhausted BETWEEN the whoami poll and the submit (parallel tabs, etc.), the server's trial_unavailable / trial_exhausted response is now caught explicitly — optimistic state cleared, account state refreshed, same modal shown. The modal copy is deliberately generic ('Out of free credits — sign up to keep going or buy credits a la carte') and doesn't telegraph what specifically is blocking the visitor, so a curious user can't trivially infer a bypass from the error wording. Signing up (fresh account, balance carries over) and buying credits a la carte (no signup, attaches to current browser) are the two paths past the block.",
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_134 = VersionInfo.of({
version: '0.2.134:0',
releaseNotes: {
en_US:
"Trial-cap IPv6 bypass fix. Discovered while investigating an operator report that a single residential network kept minting fresh trial cookies despite a strict cap. Root cause: most modern dual-stack home networks get both an IPv4 and an IPv6 prefix from their ISP, and browsers/OS routinely rotate the lower 64 bits of their IPv6 source address (RFC 4941 privacy extensions). The cap was counting trials per FULL address, so every IPv6 rotation looked like a new IP to the server and granted a fresh trial-cookie quota — meaning one laptop on one home network could mint a new cookie every hour or so. IPv4 addresses didn't have this problem (your public IPv4 is stable for the life of your ISP lease). Fix: cap counting now keys on the /64 prefix for IPv6 (the smallest network unit an ISP delegates to a subscriber, so it's the correct 'this household / this network' boundary) and the whole address for IPv4 (unchanged). The /64 prefix is the half of the IPv6 address that DOESN'T rotate, so a single device cycling through 1000 different IPv6 addresses still counts as 1 trial cookie under the cap. The DB still stores the full IP for forensics; only the count query uses the prefix. Also added a [anon-trial] minted cookie for ip=X cap_key=Y log line at every mint so operators can grep their relay logs to verify the IP detector is working (a flood of mints with ip=null means the StartOS tunnel isn't passing X-Forwarded-For). Side-effect: operators who had pre-fix mints in their DB will see their per-IPv6 row counts collapse to per-/64 totals on the next request, which may push some networks immediately over a tight cap.",
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_135 = VersionInfo.of({
version: '0.2.135:0',
releaseNotes: {
en_US:
"Subscription auto-processor — fixed a relay-only-user dead-end. Symptom: a subscription check would find a new video, queue it for approval, and log 'Kicking background processor for N approved item(s)…' — and then nothing would happen. No output in the UI, no job submitted to the relay, no error surfaced. Root cause: processItemInternally (the function the background processor uses to internally POST to /api/process) hard-required a local Gemini API key as a pre-flight gate, a legacy check from before the relay-as-provider model. Users with the relay configured but no local Gemini key would fail this check immediately, get caught by the processor's catch block, and silently land their error in the in-memory processingState.log — invisible from the dashboard. /api/process itself was never called. Fix: drop the Gemini-only gate. Auto-queue now prefers the relay when a relay URL is configured (the modern default for fresh installs and the most common setup), falls back to Gemini when only a local key is configured, and fails with a clear, user-visible error when neither is set. Sets transcriptionProvider + analysisProvider + matching model fields on the internal /api/process POST so the request reaches the right backend instead of falling through to the default 'gemini' provider that has no key. Existing Gemini-only setups keep working; existing relay-only setups now actually process subscription-discovered videos.",
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_136 = VersionInfo.of({
version: '0.2.136:0',
releaseNotes: {
en_US:
"Subscription auto-queue log clarity. The 'N in queue' counter on the subscription-check status line was counting EVERY item in the autoQueue file regardless of status — pending, approved, processing, AND completed + failed. Since the queue UI hides completed and failed items by default, operators kept asking 'why does it say 29 in queue when the queue panel is empty?' — the answer being 'because 29 includes everything ever queued, most of which is already done or failed.' Now the line splits the number into active vs done so the dashboard semantics match what the UI shows: 'X in library, Y skipped, Z seen — queue: A active, B done (C completed, D failed)'. When any failed items exist, the sub-check log also surfaces a one-liner pointing the operator at the Queue panel's 'Show all' toggle so they can view + retry. (All four counters — library, skipped, seen, and the FULL queue set including done — are still used for new-video dedup; the change is purely presentational so operators stop misreading the active load.)",
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_137 = VersionInfo.of({
version: '0.2.137:0',
releaseNotes: {
en_US:
"Audio-first \"Listen\" mode (walking mode). Recaps can now be listened to like a podcast: a new 🎧 Listen button on any saved recap opens a full-screen player that reads each topic summary aloud, back to back, using text-to-speech on the operator's relay (Kokoro via Spark Control, with ElevenLabs as a swappable cloud alternative). The summaries play sequentially — and the moment one sounds interesting, \"Listen to this part\" drops you into the real podcast/video at that exact timestamp; when that segment ends it pops back to the next summary, or you can flip \"Keep playing the original\" to let the source roll on. Lock-screen and headphone controls (play/pause, skip topic) work via the Media Session API, so it's usable hands-free with the phone in your pocket. Clips are synthesized once and cached per recap (one relay credit for the whole recap), and stream in progressively so the first topic starts within about a second while the rest generate. Available to Max users in multi-tenant mode; operators get it on their own hardware. Requires Spark Control v0.14.0+ for the Kokoro TTS backend.",
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_138 = VersionInfo.of({
version: '0.2.138:0',
releaseNotes: {
en_US:
"Audio-first \"Listen\" mode is now available to Pro subscribers too (previously Max-only). Any paid tier — Pro or Max — can voice a recap and listen to it like a podcast; operators still get it on their own hardware. No other changes.",
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_139 = VersionInfo.of({
version: '0.2.139:0',
releaseNotes: {
en_US:
"Audio-first \"Listen\" player polish: the play button now shows a pause icon while audio is playing (and vice-versa); swipe left/right on the card to move between topic summaries; a speed button cycles 1×–2× playback for both the summaries and the deep-dive source audio. Also made clip generation resilient — if a topic's audio stalls or fails, playback now skips ahead to the next ready topic instead of getting stuck, and a per-clip request timeout keeps one slow synth from blocking the rest.",
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_140 = VersionInfo.of({
version: '0.2.140:0',
releaseNotes: {
en_US:
"Audio player reliability: clips are now generated on demand as you reach each topic (and the next one is pre-fetched), and the player WAITS and keeps retrying for a topic's audio instead of skipping it. If a clip isn't ready, you'll see a clear \"Preparing the audio for this topic…\" status rather than silence. A background pass still pre-generates the whole recap in order so clips are usually ready before you arrive, and a per-clip timeout keeps one slow synthesis from blocking the rest.",
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_141 = VersionInfo.of({
version: '0.2.141:0',
releaseNotes: {
en_US:
"Audio player: (1) Pausing now sticks — moving to the next/previous topic summary while paused keeps it paused instead of auto-restarting. (2) Follow-along transcript: when you tap \"Listen to this part\" to hear the original source, the word-for-word transcript now appears and scrolls/highlights as it plays. Scroll it freely and tap any line to jump the source audio to that exact moment (just like the main app), then tap \"Back to the summary\" to return to the recap.",
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_142 = VersionInfo.of({
version: '0.2.142:0',
releaseNotes: {
en_US:
"Audio player follow-along transcript now shows just the section you're listening to, rather than the whole recap's transcript — so you can't scroll off into unrelated future sections. If you turn on \"Keep playing the original,\" the transcript automatically advances to the next section as playback crosses into it.",
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_143 = VersionInfo.of({
version: '0.2.143:0',
releaseNotes: {
en_US:
'Decouples your Recaps Pro/Max subscription from the Keysat license. Paid plans are now owned by the Recap Relay and keyed to your Recaps account — no per-user license required. Operators can set a tenant\'s tier right from the Tenants panel, and a new "Set Relay Operator Key" action links this server to the relay so it can vouch for its paid users by account-id.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_144 = VersionInfo.of({
version: '0.2.144:0',
releaseNotes: {
en_US:
'Core-decoupling fixes. The operator Tenants panel no longer clips the per-row action buttons, so the Core/Pro/Max tier selector is reachable. The Pro/Max badge a signed-in user sees now follows their relay-owned tier (set from the Tenants panel) instead of any leftover Keysat license, so the badge always matches what the operator panel shows. Free-tenant credit gating + balance display likewise key off tier, not the legacy license.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_145 = VersionInfo.of({
version: '0.2.145:0',
releaseNotes: {
en_US:
'Subscription fixes. (1) The auto-queue no longer re-adds videos you\'ve already summarized or declined — the duplicate-detection now scans your real library (it was scanning the wrong directory and finding nothing, so cleared/processed items kept reappearing). (2) In multi-tenant mode, channel/podcast subscriptions and the auto-queue are now operator-managed: signed-in tenants no longer see the operator\'s subscription queue. (Per-tenant subscriptions remain a future enhancement.)',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_146 = VersionInfo.of({
version: '0.2.146:0',
releaseNotes: {
en_US:
'Completes the subscription isolation in multi-tenant mode: library export/import no longer includes the operator\'s channel/podcast subscriptions for signed-in tenants (only the operator round-trips subscription state). Builds on 0.2.145, which fixed the auto-queue re-adding already-summarized videos and made subscriptions operator-managed so tenants stop seeing the operator\'s queue.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_147 = VersionInfo.of({
version: '0.2.147:0',
releaseNotes: {
en_US:
'Internal: subscription storage (subscriptions, auto-queue, skip/seen lists) is now per-scope and serialized against concurrent edits, with a one-time migration that moves the operator\'s existing subscription state into its own scope. No change to how subscriptions work for you today — this is the foundation for per-tenant subscriptions, leaving only the background-processor identity step (which needs on-device testing) before paid tenants can run their own.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_148 = VersionInfo.of({
version: '0.2.148:0',
releaseNotes: {
en_US:
'Fixes + polish. (1) Removes a phantom "Invalid Date · undefined topics" library entry introduced by the 0.2.147 subscription migration — the migrated subscription state files were being listed as if they were recaps; they\'re now correctly excluded from the library (and from library export). Your real recaps were never affected. (2) The operator Tenants panel hides the per-user "Tier" control on instances that don\'t hold the relay operator key, since setting a relay-owned tier only works on the operator\'s own instance. (3) The Settings panel no longer jumps back to the top or flashes when you edit a tenant (e.g. set a tier).',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_149 = VersionInfo.of({
version: '0.2.149:0',
releaseNotes: {
en_US:
'Per-tenant subscriptions. Every Pro/Max user now gets their OWN channel & podcast subscriptions and auto-queue, and approved episodes are summarized under their own account — their credits, their library, their relay pool. The background processor runs each item as its owner via a short-lived internal session (no auth bypass: a bad token just fails the item). This also fixes the operator\'s own subscription auto-processing in multi-tenant mode, which previously had no identity on its internal call. Single-mode installs are unchanged.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_150 = VersionInfo.of({
version: '0.2.150:0',
releaseNotes: {
en_US:
"Reading stays put. If you open a saved episode while another video is still processing, the live analysis windows no longer yank the page over to the new video — you keep your place. The job keeps running in the background, lands in your library when it's done, and you get a small \"ready\" notification instead. Batch-queue items follow the same rule, so a queue churning in the background won't pull you off what you're reading.",
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_151 = VersionInfo.of({
version: '0.2.151:0',
releaseNotes: {
en_US:
'Collapsing an expanded segment transcript now keeps your scroll position instead of snapping the analysis window back to the top — so you land right back on the segment you were reading.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_152 = VersionInfo.of({
version: '0.2.152:0',
releaseNotes: {
en_US: 'Self-serve subscription purchase: signed-in users can buy Pro or Max with Bitcoin (Lightning) — pick a plan, pay, and your tier activates automatically. Pay-by-card coming soon.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_153 = VersionInfo.of({
version: '0.2.153:0',
releaseNotes: {
en_US: 'Pay by card: buy Pro or Max with a card via Zaprite, alongside Pay with Bitcoin. The card option appears once the operator configures Zaprite on the relay.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_154 = VersionInfo.of({
version: '0.2.154:0',
releaseNotes: {
en_US: 'Subscription expiry reminders: a daily scan emails users a 7-day and 1-day heads-up before their prepaid Pro/Max period ends, plus a lapsed notice, via the recaps.cc SMTP. Adds a Renew deep-link and an operator test trigger.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_155 = VersionInfo.of({
version: '0.2.155:0',
releaseNotes: {
en_US: 'Pay with Bitcoin now shows an inline Lightning QR on the same screen (no redirect), matching the buy-credits flow. The pill matches the standard purple, and tier cards show each plan\'s real relay-credit allotment from the operator quota config.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_48 = VersionInfo.of({
version: '0.2.48:0',
releaseNotes: {
en_US: 'Recap now opens straight to the main page instead of the activation screen. Anyone curious about upgrading clicks the highlighted Upgrade pill in the toolbar; the "I have a key" button (also in the toolbar) is the path for users who already have a license. The activation modal still works the same way — just not auto-shown on first launch.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_49 = VersionInfo.of({
version: '0.2.49:0',
releaseNotes: {
en_US: 'In-app buy page: clicking Upgrade now opens a Recap-styled tier-picker modal instead of redirecting to Keysat\'s hosted /buy/recap page. Tiers are pulled live from Keysat\'s policies API (so operator edits in Keysat admin show up without a Recap redeploy), rendered with marketing bullets + price + cadence suffix + featured-discount badges, and checkout opens in a new tab. After payment, the modal polls /api/license/poll/<invoiceId> every 4s and activates the issued license server-side once BTCPay settles. All five Upgrade buttons (toolbar, activation screen, license block, Pro upsell, etc.) point at the new modal.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_50 = VersionInfo.of({
version: '0.2.50:0',
releaseNotes: {
en_US: 'Fix: buy modal now shows correct prices and marketing bullets. The Keysat SDK\'s listPublicPolicies() helper strips marketing_bullets, featured_discount, hidden_entitlements, and uses camelCase field names — incompatible with what the modal renders. Switched /api/license/policies to call the underlying public HTTP endpoint directly so the full snake_case shape (price_sats, marketing_bullets, is_recurring, etc.) reaches the frontend intact.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_51 = VersionInfo.of({
version: '0.2.51:0',
releaseNotes: {
en_US: 'Highlighted tier card now renders with a brighter purple border, an upward shift, and a stronger glow — much harder to miss than the previous subtle treatment. Policies cache shortened from 60s to 30s so operator edits in Keysat admin show up in the buy modal within ~30 seconds. (Note: to actually see the highlight, mark the policy as "Most popular" / Highlighted in Keysat admin first.)',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_52 = VersionInfo.of({
version: '0.2.52:0',
releaseNotes: {
en_US: 'Buy modal gains a discount-code interim step. Clicking Select on a tier now opens a small form (in Recap colors): "Discount code (optional)" + Apply / Continue without code / ← Back. Apply previews the adjusted price (base, savings, final) before the buyer is sent to BTCPay; Continue without code skips straight to checkout at the standard price. Highlighted tier badge + border now have a stronger purple visual (lifted card, 2px border, glow). Policies cache shortened from 60s to 30s so operator edits in Keysat admin (e.g. flipping "Most popular" on Pro) propagate within ~30 seconds.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_53 = VersionInfo.of({
version: '0.2.53:0',
releaseNotes: {
en_US: 'Fix: discount-code apply was failing with "missing field `product`". The Keysat /v1/purchase endpoint expects `product` as the body field name, not `product_slug` as the developer-facing spec implied — the SDK source confirms `product`. Swapped the field name; Apply code now actually creates the invoice with the discount applied.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_54 = VersionInfo.of({
version: '0.2.54:0',
releaseNotes: {
en_US: 'Fix: discount-code Apply did nothing visible (screen flashed, no preview rendered). The Keysat /v1/purchase response returns the post-discount price as `amount_sats` (and `base_price_sats` for the original sticker price), not `final_price_sats` as the developer-facing spec implied. Frontend now reads the correct field names, so the green preview panel ("Base price → You save → You pay now") renders with the real numbers after Apply.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+17
View File
@@ -0,0 +1,17 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_55 = VersionInfo.of({
version: '0.2.55:4',
releaseNotes: {
en_US:
'Three flagship features for the relay + share-URL pipeline:\n\n' +
'• Apple Podcasts share URLs (podcasts.apple.com/...?i=...) are now resolved automatically — paste the share link and Recap turns it into the underlying audio enclosure via iTunes Lookup. No more digging up the RSS feed.\n\n' +
'• Spotify episode share URLs (open.spotify.com/episode/...) are resolved via PodcastIndex when the operator configures a free PodcastIndex API key under StartOS → Recap → Actions → "Set PodcastIndex Credentials". Spotify Originals/exclusives that have no public RSS get a clear, actionable error.\n\n' +
'• Relay-URL fast-path: when transcription routes through the operator\'s relay AND we have a public source URL, Recap hands the URL directly to the relay so it can download the audio over its symmetric datacenter pipe instead of asking the buyer to upload 100MB of MP3 from a home connection. Often shaves the slowest leg off the whole pipeline. Falls back to the local-download path transparently if the relay rejects or fails.\n\n' +
'Also: when the relay reports hardware-capable backends via /relay/capabilities (operator routing prefers Parakeet), Recap skips audio chunking entirely — full file goes through in one shot.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+17
View File
@@ -0,0 +1,17 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_56 = VersionInfo.of({
version: '0.2.56:0',
releaseNotes: {
en_US:
'Buy relay credits in-app via Lightning.\n\n' +
'• New "Buy more →" button next to the credit-balance pill in the AI Providers settings block. Opens a modal styled to match the existing license-purchase flow.\n\n' +
'• Pick a bundle (1, 5, 10, or 20 credits — pricing operator-set on the relay side), modal opens the BTCPay checkout in a new tab. Once the invoice settles, the modal updates the balance pill and shows a confirmation toast — usually within a few seconds for Lightning payments.\n\n' +
'• Purchased credits never expire. They\'re consumed only after your tier\'s comped allotment runs out, so monthly comped credits keep coming as usual.\n\n' +
'• Requires the operator to wire BTCPay on the relay side (v0.2.14+ of the relay). When BTCPay isn\'t configured the "Buy more →" still appears but the modal surfaces a "contact the operator" message.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+16
View File
@@ -0,0 +1,16 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_57 = VersionInfo.of({
version: '0.2.57:10',
releaseNotes: {
en_US:
'Relay transcribe-url now uses async job polling.\n\n' +
'The relay-URL fast-path used to hold one HTTP connection open for the entire transcribe duration — minutes to tens of minutes for long audio. That broke when any proxy or load balancer in the network path silently dropped the long-running connection mid-flight, with the symptom "fetch failed (other side closed)".\n\n' +
'Recap now POSTs to the relay, gets back a job_id immediately, and polls GET /relay/jobs/:id every 5 seconds until the job lands as "complete" or "failed". Poll requests are short and cheap so no proxy in the path can drop them. The relay surfaces incremental progress messages ("downloading…", "transcribing 105 min audio…") via the poll response so the activity log keeps moving while the background work runs.\n\n' +
'Requires Recap Relay 0.2.15 or newer (which serves the async job endpoint). Older relay versions return the old sync response shape; Recap detects this and surfaces a clear "old relay version" error rather than spinning.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_58 = VersionInfo.of({
version: '0.2.58:0',
releaseNotes: {
en_US: 'Mobile rendering pass. Three CSS fixes for phone-width screens: (1) Credits pill + Buy-more button no longer overlap the URL input — they were wrapping into the search row at narrow widths. The pills now hide on phones and the same info remains accessible from Settings. (2) Library and Activity Log side panels now cover the full viewport width on phones (was 85-92vw, which left a strip of main content visible behind them). (3) The topics/segments/total stats line no longer renders twice when the results view stacks vertically — the duplicate copy in the left column is hidden on mobile. Tablet and desktop layouts unchanged.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_59 = VersionInfo.of({
version: '0.2.59:0',
releaseNotes: {
en_US: 'Topic analysis is now chunked-and-parallel on long content. Previously, a 2-hour transcript meant one giant analyze call against ~25K tokens of prompt — long prefill, single point of failure, and minutes of wall-time even on fast backends. Now, content longer than 25 minutes is split into overlapping 18-minute windows (with a 2-minute overlap on each side to handle topic boundaries that span windows) and the windows are analyzed in parallel (up to 4 concurrent calls). Results are stitched back into a single ordered list of sections with no overlap, using a "trust the second window for the overlap region" rule that prevents boundary fragmentation. Short content (≤25 min) still single-shots as before — no overhead for the common case. End-to-end speedup is typically 3-5x on 1+ hour content, and even larger when paired with the relay\'s recent JSON-mode/thinking-off change (relay 0.2.16). Works across all backends (relay, direct Gemini, OpenAI, Anthropic, Ollama) since chunking happens at the orchestrator level.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_60 = VersionInfo.of({
version: '0.2.60:0',
releaseNotes: {
en_US: 'Section-boundary precision improvement on long content. The pre-analyze coalesce step (which merged adjacent transcript entries down to ~400 max so a single huge analyze prompt would fit in a local LLM\'s context window) was a holdover from before chunking existed. With the chunked-analyze path shipped in 0.2.59, each 18-minute window only sees ~216 entries at 5-second Parakeet segments — well under any reasonable model\'s context. Coalescing first was unnecessary AND was hurting precision: section starts could only land on ~18-second boundaries because that\'s how coarsely the LLM saw the transcript. Now: content >25 min skips the coalesce entirely and feeds full-granularity entries to the chunker; content ≤25 min still coalesces (safety net for tiny-context local models on the single-shot path). Net effect on long content: section boundaries can now land within ~5 seconds of the actual topic shift instead of being rounded to ~18-second buckets. No change to transcript display granularity (always full).',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_61 = VersionInfo.of({
version: '0.2.61:0',
releaseNotes: {
en_US: 'Topics now stream into the UI as they\'re ready — no more waiting through a blank loading screen for long content. As soon as transcription completes, the results view appears with the video and an empty topics list; each topic section then pops into the list at its correct time position as the model finishes analyzing it. A small "Analyzing topics… X/Y sections ready" indicator at the bottom of the list shows the running count and disappears when all windows finish. Works in concert with the chunked-analyze (0.2.59) and skip-coalesce (0.2.60) changes: on a 2-hour video that previously sat on a spinner for 90+ seconds, you now see the first topic appear about 10-15 seconds after transcription completes, and the full set fills in over the next ~30 seconds. Short content (≤25 min, single-shot analyze) is unchanged — single call, single result render, same as before.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_62 = VersionInfo.of({
version: '0.2.62:0',
releaseNotes: {
en_US: 'Mobile UX cleanup pass plus Gemini model fixes. (1) iOS Safari no longer auto-zooms the page when you tap into the URL input on mobile — the input\'s font-size is now 16px (Safari\'s anti-zoom threshold) at every mobile breakpoint, which keeps the Summarize button from getting pushed off the right edge on focus. (2) URL input placeholder shortened from "Paste a YouTube video, channel, or podcast RSS URL…" to "YouTube or podcast URL…" so it doesn\'t truncate mid-word on phone widths. (3) The "Processing" banner that appears while a video is in flight now truncates long YouTube URLs (with tracking params) at 45 chars + … and makes the Cancel button non-shrinkable, so it can\'t get cut off on narrow screens. (4) Activity log line "Relay capabilities: max 60 min / 30 MB / chunk=2700 (routing this install to Gemini (pref=gemini_first, tier=core))" replaced with plain-language "Relay will transcribe up to 60 minutes per upload." — internal routing tiers / preferences are no longer surfaced to end users. (5) Gemini Flash model identifiers dropped the "-preview" suffix (3.1-flash-preview → 3.1-flash, 3-flash-preview → 3-flash) since Google has retired the preview-named endpoints. Pro variants are unchanged for now; if those also start 404\'ing we\'ll do the same rename pass. The transcription model dropdown now prefers gemini-3.1-flash as the default since it\'s the newest stable Flash.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_63 = VersionInfo.of({
version: '0.2.63:0',
releaseNotes: {
en_US: 'Gemini model list cleanup. The Settings → API Keys → Gemini dropdowns now show exactly 5 currently-valid models, verified against Google\'s official docs (ai.google.dev/gemini-api/docs/models): gemini-3.1-pro-preview, gemini-3-flash-preview, gemini-3.1-flash-lite, gemini-2.5-pro, and gemini-2.5-flash. Removed the never-existed gemini-3.1-flash entries from the prior release (Google never had that model — was the source of the 404 errors), the gemini-3-pro-preview entries (Google shut it down 2026-03-09), and gemini-2.0-flash (deprecated). Transcription default is now gemini-3-flash-preview (the actual "Gemini 3 Flash" — Pro-class quality at Flash pricing); analysis default remains gemini-3.1-pro-preview. Fallback chain order: Flash-first for transcription, Pro-first for analysis. Plus: minimize-video button is now visible on mobile (previously hover-only, which never fired on touch devices).',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_64 = VersionInfo.of({
version: '0.2.64:0',
releaseNotes: {
en_US: 'Speed + UI fixes for long content. (1) Client-side audio chunking now runs in parallel (up to 6 chunks in flight) instead of serially. Previously, a 90-min video split into 2 chunks would transcribe them one after the other; now they overlap, halving wall-time on the multi-chunk path. Out-of-order completion is handled correctly via index-based merge after all chunks return. (2) Topic-analysis concurrency bumped from 4 to 12 in-flight windows. Stays well under Gemini paid Tier 1 RPM caps (1000 RPM for flash, 150 RPM for pro) and saturates a single Spark for the operator-hardware path. (3) Periodic relay-status poll no longer triggers a full re-render every 60 seconds. The poll now compares previous vs new relay state and only re-renders if something actually changed — eliminates the full-screen flicker and scroll-jump that was happening on every minute mark. (4) Clicking the chevron on an activity-log group header now toggles only that group\'s body display + chevron icon instead of rebuilding the entire app DOM. No more flash-and-scroll-to-top when collapsing a log section. (5) YouTube embed no longer flickers / goes black after Cancel mid-stream. The render-time mount logic now checks whether the yt-player div already has an iframe child; if so, the existing player is left in place instead of destroyed and recreated.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_65 = VersionInfo.of({
version: '0.2.65:0',
releaseNotes: {
en_US: 'Per-minute flicker actually fixed this time. The earlier fix in 0.2.64 compared the full relay-status object before vs after each 60s poll and re-rendered if anything differed — but the server stamps a lastUpdated timestamp on every response, so the comparison always saw a diff and the full re-render fired every minute anyway. Now compares only the user-visible fields (credits remaining, tier, configured flag, last error). Render only fires when something on screen actually changed.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_66 = VersionInfo.of({
version: '0.2.66:0',
releaseNotes: {
en_US:
'Recap Relay mode: unified single-call pipeline + UI consolidation. (1) Provider picker collapsed into a two-mode chooser at the top: [Recap Relay] vs [Custom Provider / Local]. Picking Recap Relay means the operator\'s relay does the WHOLE pipeline (download + transcribe + analyze) server-side — one credit per summary, no per-step configuration needed. Picking Custom unlocks two independent per-step pickers (Gemini, Claude, OpenAI, local OpenAI-compatible, Ollama, Whisper); the relay is intentionally no longer offered for individual steps because mixing relay-for-one-step with direct-for-the-other left credit accounting in a muddy state. (2) Relay mode now calls the new POST /relay/summarize-url endpoint (requires Recap Relay v0.2.33+) instead of the old transcribeUrl + N-times-analyze fan-out. The browser receives the same SSE events (transcript_ready, sections_partial, result) it always has, so incremental rendering is unchanged from the user\'s POV — but under the hood the transcript never has to leave the relay just for Recap to slice it into 12 prompts and ship them back. Saves ~12 round-trips per long video and lets the operator\'s Settings-tab chunking knobs actually drive production behavior. (3) Migration: existing localStorage entries with both pickers set to relay → land in the new "Recap Relay" mode automatically. Anything else → "Custom Provider" mode, with any stale per-step "relay" picks bumped to the first non-relay alternative so the saved config remains valid. No data loss; no user action required after upgrade. (4) Operators running an older relay (<0.2.33) will see relay-mode jobs fail with a clear "old relay version — re-install relay 0.2.33 or newer" error message rather than a generic 404 — switch to Custom mode while you wait for the operator to upgrade.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_67 = VersionInfo.of({
version: '0.2.67:0',
releaseNotes: {
en_US:
'Chunked-transcribe robustness fixes triggered by a real-world failure on a 94-min YouTube podcast: gemini-3.1-flash-lite (the fallback model the chain walked to after gemini-2.5-flash kept 400ing on "Thinking level not supported") emitted absurd timestamps like [10:12:44] on a 45-min chunk. Those bogus offsets then poisoned the chunk-merge step (which uses a running-max comparison to dedupe boundary overlaps) — every subsequent chunk\'s entries were silently dropped because their valid offsets were "earlier" than chunk 1\'s 10-hour-claimed last entry. UI ended up showing the 94-min video as a 10:12:44 transcript with section timestamps wildly mismatched. Fixes: (1) Each audio chunk now carries its true durationSec (different from the configured chunkSeconds when it\'s the trailing chunk) so the merge step has a real time-window upper bound to validate against. (2) After parsing a chunk\'s transcribe output, drop any entry whose offset exceeds the chunk\'s end + 10s tolerance. A warning logs the count of dropped segments and the worst offset so the operator can see which model misbehaved. (3) Sort each chunk\'s entries by offset before merging — defends against models that emit segments out of chronological order (which broke the merge\'s monotonic-greater-than dedupe rule). (4) Only send thinkingConfig: {thinkingLevel: "minimal"} to Gemini 3.x flash models. Gemini 2.5 flash uses a different param shape (thinkingBudget integer) and 400s on thinkingLevel — was causing the noisy fallback spam every chunk on 2.5-flash. (5) Clearer log message for the single-shot analyze path: distinguishes "content fits in single shot (≤25 min)" from "only one analyze window planned (sparse entries — usually a sign of bad upstream transcribe data)". Net effect on the failing 94-min episode going forward: the bogus [10:12:44] entries get dropped before they poison the merge, chunk 2 + chunk 3 entries land in the final transcript, the UI shows the correct ~94-min duration, and section timestamps line up with where things actually happened in the audio.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_68 = VersionInfo.of({
version: '0.2.68:0',
releaseNotes: {
en_US:
'Two transcribe/analyze bugs surfaced by a 94-min YouTube run with gemini-3.1-flash-lite. (1) maxOutputTokens raised to 65,536 on Gemini transcribe calls. Default Gemini cap (~8,192) was silently truncating long-chunk transcripts mid-stream — observed a 45-min chunk that returned only 31:05 worth of speech (14 min missing) and another chunk that returned 2:05 of 45:00 (43 min missing). With the explicit limit, flash-lite has room to emit the full transcript; models with smaller native caps clamp internally. (2) Truncation detection: the chunk-transcribe loop now computes coverage = (last_entry_offset chunk_start) / chunk_duration. When coverage drops below 80% on chunks longer than a minute, a loud warning logs the missing-seconds count, the model name, and a suggestion to swap to a model with bigger output capacity or shrink chunk size. Coverage % also shown on the normal per-chunk completion log so the operator can spot near-misses. (3) Gap-handling in planAnalysisWindows. When TX produces a hole in the timeline (e.g., chunk 2 truncated → entries jump from 31:05 to 1:30:00), the old planner BROKE at the gap because the next entry sat past the window\'s end. Anything after the gap (a perfectly fine chunk 3 with full content) silently never got analyzed. New behavior: when a gap is detected, advance the body cursor forward to the next entry\'s body-stride boundary and keep planning windows instead of stopping. End result on the failing 94-min run: even if TX still truncates middle chunks (less likely after fix #1, but possible), the analyze pipeline will at least cover all entries that DID make it into the transcript instead of silently dropping the post-gap tail.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_69 = VersionInfo.of({
version: '0.2.69:0',
releaseNotes: {
en_US:
'Post-license-activation UX fixes. (1) Top-bar tier badge: explicit "PRO" (purple) or "MAX" (gold) pill appears next to the relay-credits count the moment a paid license activates, so the operator gets immediate visual confirmation instead of having to infer it from the Upgrade button disappearing. Reads from license entitlements directly (authoritative source) rather than the relay-side tier field (which can briefly lag during cache refresh). (2) Relay-credits pill now refreshes immediately after license activation instead of waiting for the next 60-second poll tick. loadAfterLicensed() — which runs after a successful /api/license/activate — now also calls loadRelayStatus(forceRefresh=true) which passes ?refresh=1 so the Recap server bypasses its 10-second relayState cache and pings the operator\'s relay /relay/balance right now. The relay\'s keysat-client validates the newly-saved license proof on its end and returns the new tier + monthly credit quota. Net effect: paid a BTCPay invoice for a Pro license → click "I have a key" + paste → see PRO badge + 50 (or whatever Pro\'s monthly cap is) relay credits within ~1 second, instead of seeing stale "core · 6 credits" for up to a minute. (3) Render call added after the forced refresh so the new badge + credit count surface without a page reload.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_70 = VersionInfo.of({
version: '0.2.70:0',
releaseNotes: {
en_US:
'Library entries now carry the real video / podcast title for relay-mode submissions instead of falling back to "Untitled". Root cause: when both transcribe-provider and analyze-provider are set to "Recap Relay", Recap takes a fast-path branch (server/index.js ~line 2118) that delegates the whole pipeline to the relay\'s /relay/summarize-url endpoint — meaning Recap itself never runs yt-dlp to fetch YouTube metadata, never invokes the podcast resolver, and so has no title to save with the history record. The relay DID extract the title internally (via yt-dlp during the download step) but didn\'t echo it back. Fix is split across the two packages: relay 0.2.53 adds `title` to the markComplete result envelope; this Recap release reads `relayResult.title` from the provider client return value (server/providers/relay.js), trims it, and uses it for the saveToHistory call AND the transcript_ready + result SSE events the browser consumes. Net effect: paste a YouTube URL with both providers set to relay, see "Sovereignty & Purpose in the Information Era w/ Matt Hill" (or whatever the actual YouTube title is) in the library sidebar — not "Untitled". Backwards compatible with older relays: when finalResult.title is null (relay < 0.2.53), the old titleSurrogate fallback path still applies.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_71 = VersionInfo.of({
version: '0.2.71:0',
releaseNotes: {
en_US:
'Real fix for the "Untitled" library entry bug, after v0.2.70\'s round-trip-the-title fix turned out to be incomplete. Root cause traced: server/index.js\'s relay-mode branch was passing `titleHint: titleSurrogate` to the relay, where `titleSurrogate = itemTitle || "Untitled"`. So when the operator pasted a fresh URL with no client-supplied title, Recap shipped the literal string `"Untitled"` to the relay as the title hint. The relay\'s yt-dlp-fallback gate is `if (!title && audio.title) title = audio.title` — and `!"Untitled"` is FALSE (the string is truthy), so the yt-dlp-extracted title was never used. The relay then echoed `"Untitled"` back in its completion envelope, Recap read it as authoritative, and the library entry persisted as "Untitled" forever — despite yt-dlp having successfully fetched the real video title in the background. Fixed by passing `titleHintRaw = itemTitle ? String(itemTitle).trim() : ""` (raw operator/subscription title only, NOT the surrogate). The provider\'s `title: titleHint || undefined` then correctly omits the field from the JSON body when there\'s no real title, and the relay\'s yt-dlp fallback fires as designed. `titleSurrogate` is still kept around as the display fallback for activity-log messages and for the (rare) case where the relay also can\'t determine a title. Paired with relay 0.2.57\'s defensive normalization (treats both empty string AND literal "Untitled" as "no title supplied") so older Recap clients running pre-0.2.71 also benefit once the relay is upgraded.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_72 = VersionInfo.of({
version: '0.2.72:0',
releaseNotes: {
en_US:
'Server-side defensive title sanitization at the /api/process boundary. v0.2.71 fixed the title flow by changing the BROWSER code to send empty string when itemTitle is empty (instead of the "Untitled" sentinel). But a stale browser cache — or a future client variant — might still submit the literal string "Untitled" as the title, which would defeat the whole title-resolution chain (relay\'s yt-dlp fallback gate is `if (!title && audio.title)` which treats "Untitled" as truthy). Now: the moment `req.body.title` arrives at the Recap server, we trim whitespace and strip the value to empty if it equals "" OR (case-insensitively) "untitled". All downstream code (titleSurrogate, titleHintRaw passed to the relay, the saveToHistory fallback) can rely on "itemTitle is either a real title or empty" without checking for sentinel values. Net effect: even a browser running outdated SPA code submits "Untitled" → the server normalizes it to "" → the relay receives no title field → yt-dlp fallback fires → library entry gets the real title. Belt-and-suspenders for the existing v0.2.71 + relay 0.2.57 fix.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+13
View File
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_73 = VersionInfo.of({
version: '0.2.73:0',
releaseNotes: {
en_US:
'Defensive unwrap of the relay SSE "done" event so the title fix actually works against pre-0.2.60 relays too. Backstory: relay 0.2.60 finally fixes the real root cause of the "Untitled" library bug — markComplete was double-nesting the SSE done event so `data.result.title` was undefined and the actual title lived at `data.result.result.title`. This Recap release detects BOTH shapes: if the incoming SSE event has `data.result.result` with one of the expected inner keys (transcript / analysis / title), Recap unwraps once. Otherwise it uses `data.result` as-is (the new flat shape from relay 0.2.60+). Backwards-compatible with any relay version. Net effect: a Recap install upgraded to this version reads titles correctly regardless of which relay version is running on the operator side.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})

Some files were not shown because too many files have changed in this diff Show More