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
+282
View File
@@ -0,0 +1,282 @@
// Per-user "my account" endpoints for multi-tenant Recap. Distinct
// from admin-routes.js — these are scoped to the currently signed-in
// user (req.user), not to whichever tenant id you pass in the URL.
// Used by the lite settings panel to render "Active sessions" and
// "Sign out everywhere" actions for a tenant managing their own
// account.
//
// Endpoints:
// GET /api/account/sessions — my active sessions
// DELETE /api/account/sessions/:sessionId — revoke a specific session of mine
// POST /api/account/sessions/revoke-others — revoke everything BUT the current session
//
// Multi-mode only. Single mode never mounts these — the synthetic
// "owner" user has no session table to manage.
import { getDb } from "./db.js";
import { requireUser } from "./tenant-auth.js";
import { hashPassword, validatePasswordPolicy } from "./auth-routes.js";
import fs from "fs/promises";
import path from "path";
import { getHistoryDir } from "./history.js";
export function setupAccountRoutes(app) {
// ── My license key (for the "Take Recap home" flow) ────────────────
// The Pro/Max license is a bearer credential — anyone with the LIC1-
// string can present it to the relay. We only return the CALLING
// user's own key (gated by req.user.id), never anyone else's. The key
// is stored on users.keysat_license; the parsed entitlement state
// already comes via /api/license-status. This endpoint is the one
// place the raw string is exposed — used by the lite settings panel
// to show a copy-to-clipboard "Take Recap home" block for paid
// tenants who want to also run Recap on their own StartOS server.
app.get("/api/account/license-key", requireUser, (req, res) => {
if (!req.user || !req.user.id) {
return res.status(403).json({ error: "no_user" });
}
try {
const row = getDb()
.prepare("SELECT keysat_license FROM users WHERE id = ?")
.get(req.user.id);
const key = (row?.keysat_license || "").trim();
if (!key) {
return res.status(404).json({ error: "no_license" });
}
res.json({ license_key: key });
} catch (err) {
console.error("[account] license-key lookup failed:", err);
res.status(500).json({ error: "internal_error" });
}
});
// List MY active sessions (the device list).
app.get("/api/account/sessions", requireUser, (req, res) => {
// Trial users (req.userId starts with "anon:") don't have a row in
// the sessions table — they're tracked via anon_trials. Bail with
// an empty list so the UI doesn't error.
if (!req.user || !req.user.id) {
return res.json({ sessions: [], current_session_id: null });
}
try {
const rows = getDb()
.prepare(
`SELECT id, created_at, expires_at, last_used_at, user_agent, ip_address
FROM sessions
WHERE user_id = ? AND expires_at > ?
ORDER BY last_used_at DESC, created_at DESC`,
)
.all(req.user.id, Date.now());
res.json({
sessions: rows,
// Tell the UI which row is the CURRENT session so it can
// disable the "Revoke" button for that one (sign out instead).
current_session_id: req.session?.id || null,
});
} catch (err) {
console.error("[account] sessions list failed:", err);
res.status(500).json({ error: "internal_error" });
}
});
// Revoke a single session of mine. The session id MUST belong to me
// (we WHERE clause both id AND user_id). Otherwise a tenant could
// delete someone else's session by guessing the id.
app.delete(
"/api/account/sessions/:sessionId",
requireUser,
(req, res) => {
if (!req.user || !req.user.id) {
return res.status(400).json({ error: "trial_has_no_sessions" });
}
const sessionId = req.params.sessionId;
try {
const result = getDb()
.prepare("DELETE FROM sessions WHERE id = ? AND user_id = ?")
.run(sessionId, req.user.id);
if (result.changes === 0) {
return res.status(404).json({ error: "session_not_found" });
}
res.json({ ok: true });
} catch (err) {
console.error("[account] revoke session failed:", err);
res.status(500).json({ error: "internal_error" });
}
},
);
// ── Set / clear my password ────────────────────────────────────────
// Magic-link is the primary auth. Setting a password is optional —
// makes returning sign-in faster. Set and "change" use the same
// endpoint (overwrite); the reset flow is "sign in via magic link,
// then call this endpoint with the new password."
//
// POST /api/account/password { password } — set or overwrite
// DELETE /api/account/password — clear / revert to
// magic-link-only
//
// Lives in /api/account/* (not /auth/*) because both require an
// active session and need the tenant-auth middleware to attach
// req.user — /auth/* is the public-path namespace bypassed by the
// middleware so unauthenticated visitors can request links / verify.
app.post("/api/account/password", requireUser, (req, res) => {
if (!req.user || !req.user.id) {
return res.status(401).json({ error: "auth_required" });
}
const password = req.body?.password;
const policyErr = validatePasswordPolicy(password);
if (policyErr) {
return res.status(400).json({
error: policyErr,
message:
policyErr === "password_too_short"
? "Use at least 8 characters."
: policyErr === "password_too_long"
? "256 characters max."
: "Pick a password.",
});
}
try {
const hash = hashPassword(password);
getDb()
.prepare("UPDATE users SET password_hash = ? WHERE id = ?")
.run(hash, req.user.id);
console.log(`[account] password set for user ${req.user.id}`);
res.json({ ok: true });
} catch (err) {
console.error("[account] set-password failed:", err);
res.status(500).json({ error: "internal_error" });
}
});
app.delete("/api/account/password", requireUser, (req, res) => {
if (!req.user || !req.user.id) {
return res.status(401).json({ error: "auth_required" });
}
try {
getDb()
.prepare("UPDATE users SET password_hash = NULL WHERE id = ?")
.run(req.user.id);
console.log(`[account] password cleared for user ${req.user.id}`);
res.json({ ok: true });
} catch (err) {
console.error("[account] clear-password failed:", err);
res.status(500).json({ error: "internal_error" });
}
});
// ── Delete my account ──────────────────────────────────────────────
// GDPR-style hard delete of the calling user. Confirms via a body
// sentinel ({confirm: "DELETE"}) so a stray DELETE request can't
// wipe an account by accident. After deletion the session cookie is
// cleared; the user lands back on the anonymous landing page.
//
// Cascades (via the schema's ON DELETE CASCADE) handle:
// - sessions (drop everywhere)
// - tenant_credits (drop balance row)
// - library_meta (drop the index entries)
// - subscriptions (drop billing history)
// - magic_link_tokens stay (they have no FK to users — they're
// keyed by email, harmless to leave)
// - anon_trials.converted_to_user_id SET NULL (analytics row stays)
//
// The filesystem-side history folder /data/history/<user_id>/ is
// removed separately — SQLite cascades don't reach the FS. We do
// this AFTER the DB delete so partial failures don't leave dangling
// metadata.
//
// Admin users CANNOT delete themselves via this endpoint — that
// would leave the multi-tenant Recap orphaned with no admin. They
// can downgrade themselves first via SQL if they really mean it.
app.delete("/api/account", requireUser, async (req, res) => {
if (!req.user || !req.user.id) {
return res.status(403).json({ error: "no_user" });
}
if (req.user.is_admin) {
return res.status(400).json({
error: "cannot_self_delete_admin",
message:
"Operator account can't be self-deleted (no admin would be left). Demote yourself first.",
});
}
if (req.body?.confirm !== "DELETE") {
return res.status(400).json({
error: "confirmation_required",
message: "Send {confirm: \"DELETE\"} in the body to confirm.",
});
}
const userId = req.user.id;
try {
// FK cascade handles sessions, tenant_credits, library_meta,
// subscriptions. Run inside a transaction so a crash mid-delete
// doesn't leave the user partially intact.
const db = getDb();
const result = db
.transaction(() => {
return db
.prepare("DELETE FROM users WHERE id = ?")
.run(userId);
})();
if (result.changes === 0) {
return res.status(404).json({ error: "user_not_found" });
}
} catch (err) {
console.error("[account] delete user DB op failed:", err);
return res.status(500).json({ error: "internal_error" });
}
// FS cleanup. Best-effort — failures here don't roll the user
// back into existence. Worst case is an orphan folder that the
// operator can sweep manually.
try {
const userDir = path.join(getHistoryDir(), userId);
await fs.rm(userDir, { recursive: true, force: true });
} catch (err) {
console.warn(
`[account] history dir cleanup failed for ${userId} (DB delete succeeded):`,
err,
);
}
// Clear the session cookie so the next request from this browser
// is anonymous, not "stale-session-401-prompted-to-sign-in".
res.setHeader(
"Set-Cookie",
"recap_session=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure",
);
console.log(`[account] user ${userId} deleted their account`);
res.json({ ok: true });
});
// "Sign out everywhere except this device" — useful after a
// suspicious-login email or just for hygiene. Deletes every session
// for this user EXCEPT the one carrying the request.
app.post(
"/api/account/sessions/revoke-others",
requireUser,
(req, res) => {
if (!req.user || !req.user.id) {
return res.status(400).json({ error: "trial_has_no_sessions" });
}
const currentSessionId = req.session?.id;
try {
let result;
if (currentSessionId) {
result = getDb()
.prepare(
"DELETE FROM sessions WHERE user_id = ? AND id != ?",
)
.run(req.user.id, currentSessionId);
} else {
// No current session detected (shouldn't happen if requireUser
// passed, but defensively): treat as "revoke all of mine."
result = getDb()
.prepare("DELETE FROM sessions WHERE user_id = ?")
.run(req.user.id);
}
res.json({ ok: true, revoked: result.changes });
} catch (err) {
console.error("[account] revoke-others failed:", err);
res.status(500).json({ error: "internal_error" });
}
},
);
}