0ae59f3550
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
283 lines
11 KiB
JavaScript
283 lines
11 KiB
JavaScript
// 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" });
|
|
}
|
|
},
|
|
);
|
|
}
|