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
+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
},
)