Files
recap/startos/main.ts
T
Keysat 0ae59f3550 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
2026-06-13 14:25:05 -05:00

105 lines
3.6 KiB
TypeScript

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,
{ imageId: 'main' },
sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: '/data',
readonly: false,
}),
'recap-sub',
),
exec: {
command: [
'dumb-init',
'--',
'/usr/local/bin/docker_entrypoint.sh',
],
env: {
RECAP_MODE: recapMode,
},
},
ready: {
display: i18n('Web Interface'),
fn: () =>
sdk.healthCheck.checkPortListening(effects, uiPort, {
successMessage: i18n('Recap is ready'),
errorMessage: i18n('Recap is not responding'),
}),
},
requires: [],
})
})