feat(encryptid): guardian recovery, device linking, enhanced profile

Add 2-of-3 guardian recovery system:
- Guardian invite via email or shareable link
- One-click approval page for recovery requests
- Social recovery initiation (anti-enumeration)
- 7-day recovery request expiry

Add second device linking:
- QR code + link for cross-device passkey registration
- 10-minute link expiry, one-time use

Enhanced profile page:
- Account security checklist (email, device, guardians)
- Guardian management (add/remove, max 3)
- Device linking with QR code display
- Recovery initiation form for lost devices

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-20 22:07:40 +00:00
parent b42179cff7
commit 88de4c30dd
3 changed files with 1512 additions and 5 deletions

View File

@ -344,6 +344,237 @@ export async function removeSpaceMember(
return result.count > 0; return result.count > 0;
} }
// ============================================================================
// GUARDIAN OPERATIONS
// ============================================================================
export interface StoredGuardian {
id: string;
userId: string;
name: string;
email: string | null;
guardianUserId: string | null;
status: 'pending' | 'accepted' | 'revoked';
inviteToken: string | null;
inviteExpiresAt: number | null;
acceptedAt: number | null;
createdAt: number;
}
function rowToGuardian(row: any): StoredGuardian {
return {
id: row.id,
userId: row.user_id,
name: row.name,
email: row.email || null,
guardianUserId: row.guardian_user_id || null,
status: row.status,
inviteToken: row.invite_token || null,
inviteExpiresAt: row.invite_expires_at ? new Date(row.invite_expires_at).getTime() : null,
acceptedAt: row.accepted_at ? new Date(row.accepted_at).getTime() : null,
createdAt: new Date(row.created_at).getTime(),
};
}
export async function addGuardian(
id: string,
userId: string,
name: string,
email: string | null,
inviteToken: string,
inviteExpiresAt: number,
): Promise<StoredGuardian> {
const rows = await sql`
INSERT INTO guardians (id, user_id, name, email, invite_token, invite_expires_at)
VALUES (${id}, ${userId}, ${name}, ${email}, ${inviteToken}, ${new Date(inviteExpiresAt)})
RETURNING *
`;
return rowToGuardian(rows[0]);
}
export async function getGuardians(userId: string): Promise<StoredGuardian[]> {
const rows = await sql`
SELECT * FROM guardians
WHERE user_id = ${userId} AND status != 'revoked'
ORDER BY created_at ASC
`;
return rows.map(rowToGuardian);
}
export async function getGuardianByInviteToken(token: string): Promise<StoredGuardian | null> {
const rows = await sql`SELECT * FROM guardians WHERE invite_token = ${token}`;
if (rows.length === 0) return null;
return rowToGuardian(rows[0]);
}
export async function acceptGuardianInvite(guardianId: string, guardianUserId: string): Promise<void> {
await sql`
UPDATE guardians
SET status = 'accepted', guardian_user_id = ${guardianUserId}, accepted_at = NOW(), invite_token = NULL
WHERE id = ${guardianId}
`;
}
export async function removeGuardian(guardianId: string, userId: string): Promise<boolean> {
const result = await sql`
UPDATE guardians SET status = 'revoked'
WHERE id = ${guardianId} AND user_id = ${userId}
`;
return result.count > 0;
}
export async function getGuardianById(guardianId: string): Promise<StoredGuardian | null> {
const rows = await sql`SELECT * FROM guardians WHERE id = ${guardianId}`;
if (rows.length === 0) return null;
return rowToGuardian(rows[0]);
}
export async function getGuardianships(guardianUserId: string): Promise<StoredGuardian[]> {
const rows = await sql`
SELECT * FROM guardians
WHERE guardian_user_id = ${guardianUserId} AND status = 'accepted'
ORDER BY created_at ASC
`;
return rows.map(rowToGuardian);
}
// ============================================================================
// RECOVERY REQUEST OPERATIONS
// ============================================================================
export interface StoredRecoveryRequest {
id: string;
userId: string;
status: string;
threshold: number;
approvalCount: number;
initiatedAt: number;
expiresAt: number;
completedAt: number | null;
}
function rowToRecoveryRequest(row: any): StoredRecoveryRequest {
return {
id: row.id,
userId: row.user_id,
status: row.status,
threshold: row.threshold,
approvalCount: row.approval_count,
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,
};
}
export async function createRecoveryRequest(
id: string,
userId: string,
threshold: number,
expiresAt: number,
): Promise<StoredRecoveryRequest> {
const rows = await sql`
INSERT INTO recovery_requests (id, user_id, threshold, expires_at)
VALUES (${id}, ${userId}, ${threshold}, ${new Date(expiresAt)})
RETURNING *
`;
return rowToRecoveryRequest(rows[0]);
}
export async function getRecoveryRequest(requestId: string): Promise<StoredRecoveryRequest | null> {
const rows = await sql`SELECT * FROM recovery_requests WHERE id = ${requestId}`;
if (rows.length === 0) return null;
return rowToRecoveryRequest(rows[0]);
}
export async function getActiveRecoveryRequest(userId: string): Promise<StoredRecoveryRequest | null> {
const rows = await sql`
SELECT * FROM recovery_requests
WHERE user_id = ${userId} AND status = 'pending' AND expires_at > NOW()
ORDER BY initiated_at DESC LIMIT 1
`;
if (rows.length === 0) return null;
return rowToRecoveryRequest(rows[0]);
}
export async function createRecoveryApproval(
requestId: string,
guardianId: string,
approvalToken: string,
): Promise<void> {
await sql`
INSERT INTO recovery_approvals (request_id, guardian_id, approval_token)
VALUES (${requestId}, ${guardianId}, ${approvalToken})
ON CONFLICT DO NOTHING
`;
}
export async function approveRecoveryByToken(approvalToken: string): Promise<{ requestId: string; guardianId: string } | null> {
// Find the approval
const rows = await sql`
SELECT * FROM recovery_approvals WHERE approval_token = ${approvalToken} AND approved_at IS NULL
`;
if (rows.length === 0) return null;
const row = rows[0];
// Mark approved
await sql`
UPDATE recovery_approvals SET approved_at = NOW(), approval_token = NULL
WHERE request_id = ${row.request_id} AND guardian_id = ${row.guardian_id}
`;
// Increment approval count on request
await sql`
UPDATE recovery_requests SET approval_count = approval_count + 1
WHERE id = ${row.request_id}
`;
return { requestId: row.request_id, guardianId: row.guardian_id };
}
export async function updateRecoveryRequestStatus(requestId: string, status: string): Promise<void> {
const completedAt = status === 'completed' ? sql`NOW()` : null;
await sql`
UPDATE recovery_requests SET status = ${status}, completed_at = ${completedAt}
WHERE id = ${requestId}
`;
}
export async function getRecoveryApprovals(requestId: string): Promise<Array<{ guardianId: string; approvedAt: number | null }>> {
const rows = await sql`
SELECT guardian_id, approved_at FROM recovery_approvals WHERE request_id = ${requestId}
`;
return rows.map(r => ({
guardianId: r.guardian_id,
approvedAt: r.approved_at ? new Date(r.approved_at).getTime() : null,
}));
}
// ============================================================================
// DEVICE LINK OPERATIONS
// ============================================================================
export async function createDeviceLink(token: string, userId: string, expiresAt: number): Promise<void> {
await sql`
INSERT INTO device_links (token, user_id, expires_at)
VALUES (${token}, ${userId}, ${new Date(expiresAt)})
`;
}
export async function getDeviceLink(token: string): Promise<{ userId: string; expiresAt: number; used: boolean } | null> {
const rows = await sql`SELECT * FROM device_links WHERE token = ${token}`;
if (rows.length === 0) return null;
return {
userId: rows[0].user_id,
expiresAt: new Date(rows[0].expires_at).getTime(),
used: rows[0].used,
};
}
export async function markDeviceLinkUsed(token: string): Promise<void> {
await sql`UPDATE device_links SET used = TRUE WHERE token = ${token}`;
}
// ============================================================================ // ============================================================================
// HEALTH CHECK // HEALTH CHECK
// ============================================================================ // ============================================================================

View File

@ -59,3 +59,62 @@ CREATE TABLE IF NOT EXISTS space_members (
CREATE INDEX IF NOT EXISTS idx_space_members_user_did ON space_members(user_did); CREATE INDEX IF NOT EXISTS idx_space_members_user_did ON space_members(user_did);
CREATE INDEX IF NOT EXISTS idx_space_members_space_slug ON space_members(space_slug); CREATE INDEX IF NOT EXISTS idx_space_members_space_slug ON space_members(space_slug);
-- ============================================================================
-- GUARDIAN RECOVERY (2-of-3 social recovery)
-- ============================================================================
-- Guardians: up to 3 trusted contacts per account
CREATE TABLE IF NOT EXISTS guardians (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- display name (e.g. "Mom", "Alex")
email TEXT, -- for sending invite/approval emails
guardian_user_id TEXT REFERENCES users(id), -- if they have an EncryptID account
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'revoked')),
invite_token TEXT UNIQUE, -- token for invite link
invite_expires_at TIMESTAMPTZ,
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_guardians_user_id ON guardians(user_id);
CREATE INDEX IF NOT EXISTS idx_guardians_guardian_user_id ON guardians(guardian_user_id);
CREATE INDEX IF NOT EXISTS idx_guardians_invite_token ON guardians(invite_token);
-- Recovery requests: when a user needs to recover their account
CREATE TABLE IF NOT EXISTS recovery_requests (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'cancelled', 'completed', 'expired')),
threshold INTEGER NOT NULL DEFAULT 2, -- approvals needed (2-of-3)
approval_count INTEGER NOT NULL DEFAULT 0,
initiated_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL, -- 7 days to collect approvals
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_recovery_requests_user_id ON recovery_requests(user_id);
-- Recovery approvals: guardian votes on a recovery request
CREATE TABLE IF NOT EXISTS recovery_approvals (
request_id TEXT NOT NULL REFERENCES recovery_requests(id) ON DELETE CASCADE,
guardian_id TEXT NOT NULL REFERENCES guardians(id) ON DELETE CASCADE,
approval_token TEXT UNIQUE, -- token sent via email/link for one-click approve
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (request_id, guardian_id)
);
CREATE INDEX IF NOT EXISTS idx_recovery_approvals_token ON recovery_approvals(approval_token);
-- Device link tokens: for linking a second device via QR or email
CREATE TABLE IF NOT EXISTS device_links (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL, -- 10 minutes
used BOOLEAN DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_device_links_user_id ON device_links(user_id);

File diff suppressed because it is too large Load Diff