diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 3dbfbd5f..43c7be98 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -421,6 +421,9 @@ export class RStackIdentity extends HTMLElement { // Nudge users without a second device to link one this.#checkDeviceNudge(); + // Show recovery alert dot on avatar if not set up + this.#checkRecoveryBadge(); + // Propagate login/logout across tabs via storage events window.addEventListener("storage", this.#onStorageChange); } @@ -458,6 +461,44 @@ export class RStackIdentity extends HTMLElement { } catch { /* network error โ€” let token expire naturally */ } } + async #checkRecoveryBadge() { + const session = getSession(); + if (!session?.accessToken) return; + + // Cache: only re-check every 30 minutes + const CACHE_KEY = "eid_recovery_status"; + const cached = localStorage.getItem(CACHE_KEY); + if (cached) { + try { + const { ts, ok } = JSON.parse(cached); + if (Date.now() - ts < 30 * 60 * 1000) { + if (!ok) this.#showRecoveryDot(); + return; + } + } catch { /* stale cache */ } + } + + try { + const res = await fetch(`${ENCRYPTID_URL}/api/account/status`, { + headers: { Authorization: `Bearer ${getAccessToken()}` }, + }); + if (!res.ok) return; + const status = await res.json(); + const recoveryOk = status.socialRecovery === true; + localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), ok: recoveryOk })); + if (!recoveryOk) this.#showRecoveryDot(); + } catch { /* offline */ } + } + + #showRecoveryDot() { + const wrap = this.#shadow.querySelector(".avatar-wrap"); + if (!wrap || wrap.querySelector(".recovery-alert-dot")) return; + const dot = document.createElement("span"); + dot.className = "recovery-alert-dot"; + dot.title = "Social recovery not set up"; + wrap.appendChild(dot); + } + async #checkDeviceNudge() { const session = getSession(); if (!session?.accessToken) return; @@ -1300,7 +1341,7 @@ export class RStackIdentity extends HTMLElement { let openSection: string | null = options?.openSection ?? null; // Account completion status - let acctStatus: { email: boolean; emailAddress?: string | null; multiDevice: boolean; socialRecovery: boolean; guardianCount: number; credentialCount: number } | null = null; + let acctStatus: { email: boolean; emailAddress?: string | null; multiDevice: boolean; socialRecovery: boolean; guardianCount: number; acceptedGuardianCount?: number; credentialCount: number; lastDrillAt?: number | null } | null = null; // Lazy-loaded data let guardians: { id: string; name: string; email?: string; status: string }[] = []; @@ -1308,6 +1349,13 @@ export class RStackIdentity extends HTMLElement { let guardiansLoaded = false; let guardiansLoading = false; + // Drill state + let activeDrillId: string | null = null; + let drillPollingTimer: ReturnType | null = null; + let drillStatus: { approvals: Array<{ guardianId: string; approved: boolean }>; approvalCount: number; threshold: number; status: string } | null = null; + let showWalkthrough = false; + let walkthroughStep = 0; + let devices: { credentialId: string; label: string | null; createdAt: number; lastUsed?: number; transports?: string[] }[] = []; let devicesLoaded = false; let devicesLoading = false; @@ -1326,7 +1374,10 @@ export class RStackIdentity extends HTMLElement { let emailStep: "input" | "verify" = "input"; let emailAddr = ""; - const close = () => overlay.remove(); + const close = () => { + if (drillPollingTimer) { clearInterval(drillPollingTimer); drillPollingTimer = null; } + overlay.remove(); + }; // Load account completion status const loadStatus = async () => { @@ -1388,10 +1439,40 @@ export class RStackIdentity extends HTMLElement {
+ ${showWalkthrough ? renderWalkthrough() : ""} `; attachListeners(); }; + const WALKTHROUGH_STEPS = [ + { icon: "๐Ÿ”’", iconClass: "red", title: "Account Locked", body: "Imagine you lost access to all your devices. Your passkeys are gone, and you can't sign in." }, + { icon: "๐Ÿ“ก", iconClass: "amber", title: "Guardians Notified", body: "You request account recovery. Your trusted guardians each receive a notification asking them to verify your identity." }, + { icon: "โœ…", iconClass: "amber", title: "Identity Verified", body: "Each guardian confirms it's really you โ€” through a phone call, video chat, or meeting in person. They click 'Approve' on their end." }, + { icon: "๐Ÿ”‘", iconClass: "green", title: "Key Assembled", body: "When 2 of your 3 guardians approve, their trust combines to unlock your recovery. No single guardian can do this alone." }, + { icon: "๐Ÿ›ก๏ธ", iconClass: "green", title: "Account Recovered", body: "You register a new passkey on your new device and regain full access. Your data and identity are restored." }, + ]; + + const renderWalkthrough = () => { + const step = WALKTHROUGH_STEPS[walkthroughStep]; + const dots = WALKTHROUGH_STEPS.map((_, i) => + `
` + ).join(""); + const isLast = walkthroughStep >= WALKTHROUGH_STEPS.length - 1; + return ` +
+
+
${step.icon}
+
${step.title}
+
${step.body}
+
${dots}
+
+ + ${!isLast ? `` : ``} +
+
+
`; + }; + const renderEmailSection = () => { const isOpen = openSection === "email"; const done = acctStatus ? acctStatus.email : null; @@ -1484,6 +1565,39 @@ export class RStackIdentity extends HTMLElement { `; }; + const renderGuardianPiecesSVG = (slots: Array<{ status: "empty" | "pending" | "accepted" }>, assembled: boolean) => { + const pieceClass = (s: string) => s === "accepted" ? "piece-accepted glow" : s === "pending" ? "piece-pending" : "piece-empty"; + return `
+ + + + + + + + ${/* Piece 1 (left) */""} + + + 1 + + ${/* Piece 2 (center) */""} + + + 2 + + ${/* Piece 3 (right) */""} + + + 3 + + ${/* Key icon (right side) */""} + + + + +
`; + }; + const renderRecoverySection = () => { const isOpen = openSection === "recovery"; let body = ""; @@ -1491,6 +1605,19 @@ export class RStackIdentity extends HTMLElement { if (guardiansLoading) { body = `
Loading guardians...
`; } else { + const acceptedCount = guardians.filter(g => g.status === "accepted").length; + const recoveryActive = acceptedCount >= 2; + + // Build puzzle piece slots + const slots: Array<{ status: "empty" | "pending" | "accepted" }> = []; + for (let i = 0; i < 3; i++) { + if (i < guardians.length) { + slots.push({ status: guardians[i].status === "accepted" ? "accepted" : "pending" }); + } else { + slots.push({ status: "empty" }); + } + } + const guardiansHTML = guardians.length > 0 ? `
${guardians.map(g => `
@@ -1505,13 +1632,63 @@ export class RStackIdentity extends HTMLElement {
`).join("")}
` : ""; - const infoHTML = guardians.length < 2 + // Recovery active banner + const activeBanner = recoveryActive ? ` +
+ + Recovery active โ€” ${guardiansThreshold} of ${guardians.length} guardians can recover your account +
` : ""; + + // Walkthrough link (show after 2+ guardians) + const walkthroughLink = guardians.length >= 2 ? ` +
+ Preview how recovery works +
` : ""; + + // Drill section (only when recovery active) + let drillHTML = ""; + if (recoveryActive) { + const lastDrill = acctStatus?.lastDrillAt; + const lastDrillStr = lastDrill ? new Date(lastDrill).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : null; + + if (activeDrillId && drillStatus) { + // Live drill view + const drillSlots: Array<{ status: "empty" | "pending" | "accepted" }> = []; + for (const g of guardians) { + if (g.status !== "accepted") continue; + const approval = drillStatus.approvals.find(a => a.guardianId === g.id); + drillSlots.push({ status: approval?.approved ? "accepted" : "pending" }); + } + while (drillSlots.length < 3) drillSlots.push({ status: "empty" }); + const drillComplete = drillStatus.status === "approved" || drillStatus.status === "completed"; + + drillHTML = `
+
+
${drillComplete ? "Drill Complete!" : "Recovery Drill in Progress..."}
+ ${renderGuardianPiecesSVG(drillSlots, drillComplete)} +
+ ${drillStatus.approvalCount}/${drillStatus.threshold} guardians approved +
+ ${drillComplete ? `
Your recovery setup is working!
` : `
Checking every 5 seconds...
`} +
+
`; + } else { + drillHTML = `
+ + ${lastDrillStr ? `
Last drill: ${lastDrillStr}
` : `
No drill run yet โ€” test your setup!
`} +
`; + } + } + + const infoHTML = !recoveryActive ? `
Add at least 2 trusted guardians to enable social recovery. Threshold: ${guardiansThreshold} of ${Math.max(guardians.length, 2)} needed to recover.
` - : `
Social recovery is active. ${guardiansThreshold} of ${guardians.length} guardians needed to recover your account.
`; + : `
Recovery guardians can also help override account freezes and flow restrictions in emergencies.
`; body = `

Choose trusted contacts who can help recover your account.

+ ${activeBanner} + ${renderGuardianPiecesSVG(slots, recoveryActive)} ${guardians.length < 3 ? `
@@ -1519,15 +1696,18 @@ export class RStackIdentity extends HTMLElement {
` : ""} ${guardiansHTML} ${infoHTML} + ${walkthroughLink} + ${drillHTML}
`; } } const done = acctStatus ? acctStatus.socialRecovery : null; + const urgentClass = done === false ? " recovery-urgent" : ""; return `
${body} @@ -1975,7 +2155,9 @@ export class RStackIdentity extends HTMLElement { const data = await res.json(); if (!res.ok) throw new Error(data.error || "Failed to add guardian"); guardians.push({ id: data.guardian.id, name: data.guardian.name, email: data.guardian.email, status: data.guardian.status }); - if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.socialRecovery = guardians.length >= 2; } + const accepted = guardians.filter(g => g.status === "accepted").length; + if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.acceptedGuardianCount = accepted; acctStatus.socialRecovery = accepted >= 2; } + localStorage.removeItem("eid_recovery_status"); render(); setTimeout(() => (overlay.querySelector("#acct-guardian-name") as HTMLInputElement)?.focus(), 50); } catch (e: any) { @@ -2003,7 +2185,9 @@ export class RStackIdentity extends HTMLElement { }); if (!res.ok) throw new Error("Failed to remove guardian"); guardians = guardians.filter(g => g.id !== id); - if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.socialRecovery = guardians.length >= 2; } + const acceptedAfter = guardians.filter(g => g.status === "accepted").length; + if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.acceptedGuardianCount = acceptedAfter; acctStatus.socialRecovery = acceptedAfter >= 2; } + localStorage.removeItem("eid_recovery_status"); render(); } catch (e: any) { if (err) err.textContent = e.message; @@ -2011,6 +2195,72 @@ export class RStackIdentity extends HTMLElement { }); }); + // Recovery: start drill + overlay.querySelector('[data-action="start-drill"]')?.addEventListener("click", async () => { + const btn = overlay.querySelector('[data-action="start-drill"]') as HTMLButtonElement; + const err = overlay.querySelector("#recovery-error") as HTMLElement; + if (!confirm("This will send a test notification to your guardians. They'll need to verify your identity and approve. Continue?")) return; + btn.disabled = true; btn.innerHTML = ' Starting drill...'; + try { + const res = await fetch(`${ENCRYPTID_URL}/api/recovery/drill/initiate`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` }, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to start drill"); + activeDrillId = data.drillId; + drillStatus = { approvals: data.guardians.map((g: any) => ({ guardianId: g.id, approved: false })), approvalCount: 0, threshold: 2, status: "pending" }; + // Start polling + drillPollingTimer = setInterval(async () => { + if (!activeDrillId) return; + try { + const pollRes = await fetch(`${ENCRYPTID_URL}/api/recovery/drill/${activeDrillId}/status`, { + headers: { Authorization: `Bearer ${getAccessToken()}` }, + }); + if (pollRes.ok) { + const pollData = await pollRes.json(); + drillStatus = pollData; + render(); + if (pollData.status === "approved" || pollData.status === "completed") { + if (drillPollingTimer) clearInterval(drillPollingTimer); + drillPollingTimer = null; + // Complete the drill + await fetch(`${ENCRYPTID_URL}/api/recovery/drill/${activeDrillId}/complete`, { + method: "POST", + headers: { Authorization: `Bearer ${getAccessToken()}` }, + }); + if (acctStatus) acctStatus.lastDrillAt = Date.now(); + // Clear recovery status cache so badge refreshes + localStorage.removeItem("eid_recovery_status"); + render(); + } + } + } catch { /* offline */ } + }, 5000); + render(); + } catch (e: any) { + btn.disabled = false; btn.innerHTML = "๐Ÿงช Run Recovery Drill"; + if (err) err.textContent = e.message; + } + }); + + // Recovery: preview walkthrough + overlay.querySelector('[data-action="preview-recovery"]')?.addEventListener("click", () => { + showWalkthrough = true; + walkthroughStep = 0; + render(); + }); + + // Walkthrough navigation + overlay.querySelector('[data-action="walkthrough-next"]')?.addEventListener("click", () => { + walkthroughStep++; + if (walkthroughStep >= 5) { showWalkthrough = false; walkthroughStep = 0; } + render(); + }); + overlay.querySelector('[data-action="walkthrough-skip"]')?.addEventListener("click", () => { + showWalkthrough = false; walkthroughStep = 0; render(); + }); + // Address: save overlay.querySelector('[data-action="save-address"]')?.addEventListener("click", async () => { const street = (overlay.querySelector("#acct-street") as HTMLInputElement)?.value.trim() || ""; @@ -2559,6 +2809,17 @@ const STYLES = ` display: flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--rs-bg-surface); line-height: 1; } +/* Recovery alert dot on avatar */ +.recovery-alert-dot { + position: absolute; bottom: -1px; right: -1px; + width: 10px; height: 10px; border-radius: 50%; + background: #f87171; border: 2px solid var(--rs-bg-surface, #1e1e2e); + animation: recovery-pulse 2s ease-in-out infinite; +} +@keyframes recovery-pulse { + 0%, 100% { box-shadow: 0 0 4px rgba(248,113,113,0.4); } + 50% { box-shadow: 0 0 10px rgba(248,113,113,0.8); } +} /* Persona switcher in dropdown */ .dropdown-label { @@ -2871,6 +3132,79 @@ const ACCOUNT_MODAL_STYLES = ` margin-right: 12px; margin-bottom: 2px; cursor: pointer; } .conn-share-opt input[type="checkbox"] { margin: 0; accent-color: #06b6d4; } + +/* Guardian puzzle pieces SVG visualization */ +.guardian-viz { display: flex; align-items: center; justify-content: center; gap: 8px; margin: 12px 0; } +.guardian-viz svg { transition: all 0.5s ease; } +.piece-empty { fill: #334155; opacity: 0.4; } +.piece-pending { fill: #fbbf24; opacity: 0.7; } +.piece-accepted { fill: #34d399; } +.piece-accepted.glow { filter: drop-shadow(0 0 6px rgba(52,211,153,0.6)); } +.key-outline { fill: none; stroke: #334155; stroke-width: 2; stroke-dasharray: 4 2; opacity: 0.4; } +.key-assembled { fill: url(#keyGrad); stroke: #34d399; stroke-width: 2; stroke-dasharray: none; opacity: 1; filter: drop-shadow(0 0 8px rgba(52,211,153,0.5)); } +@keyframes piece-slide { from { transform: translateX(0); } to { transform: translateX(var(--slide-x, 0px)) translateY(var(--slide-y, 0px)); } } +@keyframes key-glow { 0% { filter: drop-shadow(0 0 4px rgba(52,211,153,0.3)); } 100% { filter: drop-shadow(0 0 12px rgba(52,211,153,0.7)); } } + +/* Recovery active banner */ +.recovery-active-banner { + display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 8px; + background: rgba(52,211,153,0.08); border: 1px solid rgba(52,211,153,0.2); + color: #34d399; font-size: 0.82rem; font-weight: 500; margin-bottom: 12px; +} +.recovery-active-banner svg { flex-shrink: 0; } + +/* Drill section */ +.drill-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--rs-border); } +.drill-btn { + padding: 8px 16px; border-radius: 8px; border: 1px solid rgba(251,191,36,0.3); + background: rgba(251,191,36,0.08); color: #fbbf24; font-size: 0.82rem; font-weight: 600; + cursor: pointer; transition: all 0.2s; width: 100%; +} +.drill-btn:hover { background: rgba(251,191,36,0.15); border-color: rgba(251,191,36,0.5); } +.drill-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.drill-timestamp { font-size: 0.72rem; color: var(--rs-text-muted); margin-top: 6px; } +.drill-live { padding: 12px; border-radius: 8px; background: var(--rs-bg-hover); border: 1px solid var(--rs-border); margin-top: 8px; } +.drill-live-title { font-size: 0.82rem; font-weight: 600; color: #fbbf24; margin-bottom: 8px; } +.drill-success { color: #34d399; font-weight: 600; font-size: 0.85rem; text-align: center; margin: 12px 0; } + +/* Solo walkthrough overlay */ +.walkthrough-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.7); + -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); + display: flex; align-items: center; justify-content: center; z-index: 10001; + animation: fadeIn 0.3s; +} +.walkthrough-card { + background: var(--rs-bg-surface, #1e1e2e); border: 1px solid var(--rs-border, #334155); + border-radius: 16px; padding: 2rem; max-width: 400px; width: 90%; + text-align: center; animation: slideUp 0.3s; +} +.walkthrough-icon { font-size: 3rem; margin-bottom: 0.5rem; transition: all 0.5s ease; } +.walkthrough-icon.red { filter: drop-shadow(0 0 12px rgba(239,68,68,0.6)); } +.walkthrough-icon.green { filter: drop-shadow(0 0 12px rgba(52,211,153,0.6)); } +.walkthrough-icon.amber { filter: drop-shadow(0 0 12px rgba(251,191,36,0.6)); } +.walkthrough-title { + font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem; + background: linear-gradient(135deg, #06b6d4, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} +.walkthrough-body { color: var(--rs-text-secondary, #94a3b8); font-size: 0.85rem; line-height: 1.6; margin-bottom: 1.5rem; } +.walkthrough-progress { display: flex; gap: 6px; justify-content: center; margin-bottom: 1rem; } +.walkthrough-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--rs-border, #334155); transition: background 0.3s; } +.walkthrough-dot.active { background: #06b6d4; } +.walkthrough-dot.done { background: #34d399; } +.walkthrough-nav { display: flex; gap: 8px; justify-content: center; } +.walkthrough-btn { + padding: 8px 20px; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; + cursor: pointer; transition: all 0.2s; +} +.walkthrough-btn--next { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; } +.walkthrough-btn--next:hover { transform: translateY(-1px); } +.walkthrough-btn--skip { background: transparent; color: var(--rs-text-muted, #64748b); border: 1px solid var(--rs-border, #334155); } +.walkthrough-btn--skip:hover { color: var(--rs-text-primary); } + +/* Recovery section urgent pulsing */ +.status-dot.pending.recovery-urgent { animation: recovery-pulse 2s ease-in-out infinite; } +@keyframes recovery-pulse { 0%,100% { box-shadow: 0 0 4px rgba(248,113,113,0.4); } 50% { box-shadow: 0 0 10px rgba(248,113,113,0.8); } } `; const ONBOARDING_STYLES = ` diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 933b03fc..28998183 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -565,6 +565,7 @@ export interface StoredRecoveryRequest { initiatedAt: number; expiresAt: number; completedAt: number | null; + isDrill: boolean; } function rowToRecoveryRequest(row: any): StoredRecoveryRequest { @@ -577,6 +578,7 @@ function rowToRecoveryRequest(row: any): StoredRecoveryRequest { initiatedAt: new Date(row.initiated_at).getTime(), expiresAt: new Date(row.expires_at).getTime(), completedAt: row.completed_at ? new Date(row.completed_at).getTime() : null, + isDrill: row.is_drill ?? false, }; } @@ -654,6 +656,30 @@ export async function updateRecoveryRequestStatus(requestId: string, status: str `; } +export async function createDrillRequest( + id: string, + userId: string, + threshold: number, + expiresAt: number, +): Promise { + const rows = await sql` + INSERT INTO recovery_requests (id, user_id, threshold, expires_at, is_drill) + VALUES (${id}, ${userId}, ${threshold}, ${new Date(expiresAt)}, TRUE) + RETURNING * + `; + return rowToRecoveryRequest(rows[0]); +} + +export async function setUserLastDrillAt(userId: string): Promise { + await sql`UPDATE users SET last_drill_at = NOW() WHERE id = ${userId}`; +} + +export async function getUserLastDrillAt(userId: string): Promise { + const rows = await sql`SELECT last_drill_at FROM users WHERE id = ${userId}`; + if (!rows.length || !rows[0].last_drill_at) return null; + return new Date(rows[0].last_drill_at).getTime(); +} + export async function getRecoveryApprovals(requestId: string): Promise> { const rows = await sql` SELECT guardian_id, approved_at FROM recovery_approvals WHERE request_id = ${requestId} diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index fdf807b8..cec96d9d 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.sql @@ -581,6 +581,13 @@ CREATE TABLE IF NOT EXISTS space_email_forwarding ( PRIMARY KEY (space_slug, user_did) ); +-- ============================================================================ +-- SOCIAL RECOVERY DRILL (test recovery flow without actual recovery) +-- ============================================================================ + +ALTER TABLE recovery_requests ADD COLUMN IF NOT EXISTS is_drill BOOLEAN DEFAULT FALSE; +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_drill_at TIMESTAMPTZ; + -- Per-space agent mailbox credentials ({space}-agent@rspace.online) CREATE TABLE IF NOT EXISTS agent_mailboxes ( space_slug TEXT PRIMARY KEY, diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index bd376737..8b00c235 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -148,6 +148,9 @@ import { getProfileEmailsByDids, getAgentMailbox, listAllAgentMailboxes, + createDrillRequest, + setUserLastDrillAt, + getUserLastDrillAt, } from './db.js'; import { isMailcowConfigured, @@ -1582,17 +1585,21 @@ app.get('/api/account/status', async (c) => { const creds = await getUserCredentials(userId); const hasMultiDevice = creds.length > 1; - // Check guardians + // Check guardians (count accepted ones for recovery readiness) let guardianCount = 0; + let acceptedGuardianCount = 0; try { - const rows = await sql`SELECT COUNT(*) as count FROM guardians WHERE user_id = ${userId} AND status != 'removed'`; - guardianCount = parseInt(rows[0]?.count || '0'); + const rows = await sql`SELECT status, COUNT(*) as count FROM guardians WHERE user_id = ${userId} AND status != 'revoked' GROUP BY status`; + for (const r of rows) { + const cnt = parseInt(r.count || '0'); + guardianCount += cnt; + if (r.status === 'accepted') acceptedGuardianCount = cnt; + } } catch { /* ignore */ } - const hasRecovery = guardianCount >= 2; + const hasRecovery = acceptedGuardianCount >= 2; - // Check encrypted backup - // (This is client-side localStorage, but we can infer from whether they have any synced docs) - // For now, we just return the server-side info and let the client check localStorage + // Last drill timestamp + const lastDrillAt = await getUserLastDrillAt(userId); return c.json({ email: hasEmail, @@ -1601,6 +1608,8 @@ app.get('/api/account/status', async (c) => { socialRecovery: hasRecovery, credentialCount: creds.length, guardianCount, + acceptedGuardianCount, + lastDrillAt, }); }); @@ -3260,7 +3269,10 @@ app.get('/approve', (c) => { .status.success { background: rgba(34,197,94,0.1); color: #22c55e; } .status.error { background: rgba(239,68,68,0.1); color: #ef4444; } .status.warning { background: rgba(234,179,8,0.1); color: #eab308; } + .status.drill { background: rgba(251,191,36,0.1); color: #fbbf24; } + .drill-badge { display: inline-block; padding: 3px 10px; border-radius: 6px; background: rgba(251,191,36,0.15); color: #fbbf24; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; margin-bottom: 0.75rem; } .btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #22c55e, #16a34a); color: #fff; border: none; border-radius: 8px; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; margin-top: 1rem; } + .btn.drill { background: linear-gradient(90deg, #fbbf24, #f59e0b); color: #1a1a2e; } .btn:hover { transform: translateY(-2px); } .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .hidden { display: none; } @@ -3269,7 +3281,8 @@ app.get('/approve', (c) => {
🔐
-

Recovery Approval

+

Recovery Approval

+
Verifying approval link...
@@ -3279,7 +3292,10 @@ app.get('/approve', (c) => { const approvalToken = params.get('token'); const statusEl = document.getElementById('status'); const btn = document.getElementById('approve-btn'); + const titleEl = document.getElementById('title'); + const drillBadge = document.getElementById('drill-badge'); + // Pre-check if this is a drill by looking at query params or checking the token info async function init() { if (!approvalToken) { statusEl.className = 'status error'; @@ -3308,9 +3324,18 @@ app.get('/approve', (c) => { btn.classList.add('hidden'); return; } - statusEl.className = 'status success'; - statusEl.innerHTML = 'Recovery approved! ' + data.approvalCount + ' of ' + data.threshold + ' approvals received.' + - (data.approvalCount >= data.threshold ? '

The account owner can now recover their account.' : '

Waiting for more guardians to approve.'); + // Show drill badge if this was a drill + if (data.isDrill) { + drillBadge.classList.remove('hidden'); + titleEl.textContent = 'Recovery Drill'; + statusEl.className = 'status success'; + statusEl.innerHTML = 'Drill approved! ' + data.approvalCount + ' of ' + data.threshold + ' approvals received.' + + (data.approvalCount >= data.threshold ? '

The drill is complete. The account owner\\'s recovery setup is working!' : '

Waiting for more guardians to approve the drill.'); + } else { + statusEl.className = 'status success'; + statusEl.innerHTML = 'Recovery approved! ' + data.approvalCount + ' of ' + data.threshold + ' approvals received.' + + (data.approvalCount >= data.threshold ? '

The account owner can now recover their account.' : '

Waiting for more guardians to approve.'); + } btn.classList.add('hidden'); } catch { statusEl.className = 'status error'; @@ -3345,15 +3370,26 @@ app.post('/api/recovery/social/approve', async (c) => { if (request.approvalCount >= request.threshold && request.status === 'pending') { await updateRecoveryRequestStatus(request.id, 'approved'); - // Notify account owner that recovery is approved + if (request.isDrill) { + // Auto-complete drills when threshold met + record timestamp + await updateRecoveryRequestStatus(request.id, 'completed'); + await setUserLastDrillAt(request.userId); + } + + // Notify account owner + const eventType = request.isDrill ? 'recovery_drill_complete' : 'recovery_approved'; + const title = request.isDrill ? 'Recovery drill complete!' : 'Account recovery approved'; + const body = request.isDrill + ? `${request.approvalCount}/${request.threshold} guardians approved your drill. Your recovery setup is working!` + : `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`; notify({ userDid: request.userId, category: 'system', - eventType: 'recovery_approved', - title: 'Account recovery approved', - body: `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`, - metadata: { recoveryRequestId: request.id }, - actionUrl: `https://auth.rspace.online/recover/social?id=${request.id}`, + eventType, + title, + body, + metadata: { recoveryRequestId: request.id, isDrill: request.isDrill }, + actionUrl: request.isDrill ? undefined : `https://auth.rspace.online/recover/social?id=${request.id}`, }).catch(() => {}); } @@ -3362,6 +3398,7 @@ app.post('/api/recovery/social/approve', async (c) => { approvalCount: request.approvalCount, threshold: request.threshold, status: request.approvalCount >= request.threshold ? 'approved' : 'pending', + isDrill: request.isDrill, }); }); @@ -3418,6 +3455,167 @@ app.post('/api/recovery/social/:id/complete', async (c) => { }); }); +// ============================================================================ +// RECOVERY DRILL (test recovery flow) +// ============================================================================ + +/** + * POST /api/recovery/drill/initiate โ€” start a recovery drill (auth required) + * Sends drill-flagged notifications to guardians + */ +app.post('/api/recovery/drill/initiate', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const userId = claims.sub as string; + const user = await getUserById(userId); + if (!user) return c.json({ error: 'User not found' }, 404); + + const guardians = await getGuardians(userId); + const accepted = guardians.filter(g => g.status === 'accepted'); + if (accepted.length < 2) { + return c.json({ error: 'Need at least 2 accepted guardians to run a drill' }, 400); + } + + // Check no active non-drill recovery request + const existing = await getActiveRecoveryRequest(userId); + if (existing && !existing.isDrill) { + return c.json({ error: 'An active recovery request already exists. Cannot start a drill.' }, 400); + } + + // Create drill request (24h expiry, same 2-of-3 threshold) + const drillId = generateToken(); + const expiresAt = Date.now() + 24 * 60 * 60 * 1000; + await createDrillRequest(drillId, userId, 2, expiresAt); + + // Create approval tokens and notify guardians + const drillGuardians: Array<{ id: string; name: string; status: string }> = []; + for (const guardian of accepted) { + const approvalToken = generateToken(); + await createRecoveryApproval(drillId, guardian.id, approvalToken); + + const approveUrl = `https://auth.rspace.online/approve?token=${approvalToken}`; + drillGuardians.push({ id: guardian.id, name: guardian.name, status: 'pending' }); + + // In-app notification + if (guardian.guardianUserId) { + notify({ + userDid: guardian.guardianUserId, + category: 'system', + eventType: 'recovery_drill', + title: `Recovery drill from ${user.username}`, + body: `This is a test! ${user.username} is testing their recovery setup. Please verify and approve.`, + actionUrl: approveUrl, + actorUsername: user.username, + metadata: { recoveryRequestId: drillId, guardianId: guardian.id, isDrill: true }, + }).catch(() => {}); + } + + // Email notification + if (guardian.email && smtpTransport) { + try { + await smtpTransport.sendMail({ + from: CONFIG.smtp.from, + to: guardian.email, + subject: `Recovery DRILL from ${user.username} โ€” rStack`, + text: [ + `Hi ${guardian.name},`, + '', + `This is a TEST! ${user.username} is running a recovery drill to test their guardian setup.`, + '', + 'Please verify their identity and approve:', + approveUrl, + '', + 'This is NOT a real recovery โ€” just a test. The drill expires in 24 hours.', + '', + 'โ€” rStack Identity', + ].join('\n'), + html: ` + + + + +
+ + + + + +
+
🔐
+

Recovery DRILL

+
TEST ONLY
+
+

Hi ${guardian.name},

+

${user.username} is running a recovery drill to test their guardian setup.

+

This is NOT a real recovery. Please verify their identity and approve the drill.

+
+ Approve Drill +
+

This drill expires in 24 hours.

+
+
+`, + }); + } catch (err) { + console.error(`EncryptID: Failed to send drill email to ${guardian.email}:`, err); + } + } + } + + return c.json({ + success: true, + drillId, + guardians: drillGuardians, + }); +}); + +/** + * GET /api/recovery/drill/:id/status โ€” check drill status (auth required) + */ +app.get('/api/recovery/drill/:id/status', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const { id } = c.req.param(); + const request = await getRecoveryRequest(id); + if (!request) return c.json({ error: 'Not found' }, 404); + if (!request.isDrill) return c.json({ error: 'Not a drill' }, 400); + if (request.userId !== claims.sub) return c.json({ error: 'Not your drill' }, 403); + + const approvals = await getRecoveryApprovals(request.id); + return c.json({ + id: request.id, + status: request.status, + threshold: request.threshold, + approvalCount: request.approvalCount, + approvals: approvals.map(a => ({ + guardianId: a.guardianId, + approved: !!a.approvedAt, + })), + expiresAt: request.expiresAt, + }); +}); + +/** + * POST /api/recovery/drill/:id/complete โ€” mark drill as completed (no recovery token issued) + */ +app.post('/api/recovery/drill/:id/complete', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const { id } = c.req.param(); + const request = await getRecoveryRequest(id); + if (!request) return c.json({ error: 'Not found' }, 404); + if (!request.isDrill) return c.json({ error: 'Not a drill' }, 400); + if (request.userId !== claims.sub) return c.json({ error: 'Not your drill' }, 403); + + await updateRecoveryRequestStatus(id, 'completed'); + await setUserLastDrillAt(claims.sub as string); + + return c.json({ success: true, message: 'Drill completed successfully!' }); +}); + // ============================================================================ // DEVICE LINKING ROUTES // ============================================================================