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:
parent
b42179cff7
commit
88de4c30dd
|
|
@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue