const crypto = require("crypto"); const DELAY_MS = 3000; const EXPIRES_MS = 30000; const MAX_PENDING = 20; const DESTRUCTIVE_PATH = /(?:^|\/)(?:delete|remove|clear|reset|renew|uninstall|cleanup|archive|revoke|unlink|unset)(?:\/|$)/i; function isDestructivePath(value) { return DESTRUCTIVE_PATH.test(normalizeAction(value)); } function issueConfirmation(req, action) { const normalizedAction = normalizeAction(action); if (!req.session?.user?.id) throw new Error("Authentication is required."); if (!isDestructivePath(normalizedAction)) throw new Error("This action is not registered as destructive."); const now = Date.now(); prune(req.session, now); const token = crypto.randomBytes(32).toString("base64url"); const entries = req.session.destructive_confirmations || {}; entries[token] = { action: normalizedAction, user_id: req.session.user.id, not_before: now + DELAY_MS, expires_at: now + EXPIRES_MS }; const keys = Object.keys(entries); for (const key of keys.slice(0, Math.max(0, keys.length - MAX_PENDING))) delete entries[key]; req.session.destructive_confirmations = entries; return { token, not_before: now + DELAY_MS, expires_at: now + EXPIRES_MS, delay_seconds: DELAY_MS / 1000 }; } function consumeConfirmation(req, action, token = req.body?.confirmation_token || req.get?.("x-confirmation-token")) { const normalizedAction = normalizeAction(action); const entries = req.session?.destructive_confirmations || {}; const entry = token ? entries[token] : null; if (!entry) return { valid: false, reason: "missing_or_invalid" }; delete entries[token]; req.session.destructive_confirmations = entries; const now = Date.now(); if (entry.user_id !== req.session.user?.id || entry.action !== normalizedAction) { return { valid: false, reason: "action_mismatch" }; } if (now < entry.not_before) return { valid: false, reason: "too_early" }; if (now > entry.expires_at) return { valid: false, reason: "expired" }; return { valid: true }; } function normalizeAction(value) { const raw = String(value || "").trim(); if (!raw.startsWith("/")) return ""; try { return new URL(raw, "http://lumi.local").pathname; } catch { return ""; } } function prune(session, now = Date.now()) { const entries = session.destructive_confirmations || {}; for (const [token, entry] of Object.entries(entries)) { if (!entry || entry.expires_at < now) delete entries[token]; } session.destructive_confirmations = entries; } module.exports = { DELAY_MS, EXPIRES_MS, isDestructivePath, issueConfirmation, consumeConfirmation, normalizeAction };