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
+36
View File
@@ -25,6 +25,14 @@ let startosConfigPath = null;
export let serverApiKey = "";
// Core-decoupling shared "operator key" — read live from the StartOS
// config sidecar the same way serverApiKey is, so the operator can set it
// via the "Set Relay Operator Key" action without a service restart.
// `RECAP_RELAY_OPERATOR_KEY` env pins the value (local dev). Consumed by
// relay-default.js's getRelayOperatorKey(); see that for the semantics.
let envRelayOperatorKey = "";
export let relayOperatorKey = "";
// ── Init ────────────────────────────────────────────────────────────────────
// Call once at boot. Sets up paths, reads the initial value, kicks off the
// poll loop. Idempotent if you really want to call it twice (the interval
@@ -35,13 +43,17 @@ export async function initConfig({ dataDir }) {
startosConfigPath = path.join(configDir, "startos-config.json");
envApiKey = process.env.GEMINI_API_KEY || "";
serverApiKey = envApiKey;
envRelayOperatorKey = (process.env.RECAP_RELAY_OPERATOR_KEY || "").trim();
relayOperatorKey = envRelayOperatorKey;
await fs.mkdir(configDir, { recursive: true }).catch(() => {});
await refreshServerApiKey("startup");
await refreshRelayOperatorKey("startup");
const pollMs = parseInt(process.env.RECAP_CONFIG_POLL_MS || "3000", 10);
setInterval(() => {
refreshServerApiKey("config poll").catch(() => {});
refreshRelayOperatorKey("config poll").catch(() => {});
}, pollMs);
}
@@ -75,6 +87,27 @@ async function refreshServerApiKey(reason) {
}
}
async function readRelayOperatorKeyFromConfig() {
try {
const content = await fs.readFile(startosConfigPath, "utf-8");
const config = JSON.parse(content);
return (config.recap_relay_operator_key || "").trim();
} catch {
return "";
}
}
async function refreshRelayOperatorKey(reason) {
if (envRelayOperatorKey) return; // env var pins the value
const next = await readRelayOperatorKeyFromConfig();
if (next !== relayOperatorKey) {
relayOperatorKey = next;
console.log(
`[config] relay operator key ${next ? "loaded" : "cleared"} (${reason})`,
);
}
}
// ── Public helpers ──────────────────────────────────────────────────────────
// Resolves the per-request key — either the client's own (BYO) or the
// server's stored key (when the client signals USE_SERVER_KEY or sends
@@ -132,4 +165,7 @@ export async function mergeConfig(patch) {
if (Object.prototype.hasOwnProperty.call(patch, "gemini_api_key")) {
await refreshServerApiKey("merge config");
}
if (Object.prototype.hasOwnProperty.call(patch, "recap_relay_operator_key")) {
await refreshRelayOperatorKey("merge config");
}
}