// 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// 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" }); } }, ); }