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