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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user