import type { DevicePlatform } from '@chat-app/shared/supabase'; import { type SyncCtx, wrapForOneDevice } from './conversationKeySync'; import { supabase } from './supabase'; // Device-approval flow (Phase 1). // --------------------------------------------------------------------------- // When the user registers a brand-new device on top of an existing one, the // existing device must explicitly approve it before any conv-key wraps are // created. This module: // // 1. On startup, fetches every device row owned by the user and surfaces // the ones that aren't this device, aren't already approved, and aren't // dismissed. Covers the "I was offline when the new device registered" // case. // 2. Subscribes to realtime INSERTs on `devices` for the user's id, so a // device that registers WHILE this client is online raises a banner // immediately. // 3. Persists approve/deny decisions in localStorage so a reload doesn't // ask again for a device the user already answered for. // // Approval call: re-uses `wrapForOneDevice` from conversationKeySync — that // helper already walks every shared conversation and writes the key bundle // for the target device. export interface PendingApproval { deviceId: string; userId: string; name: string; platform: DevicePlatform | string; createdAt: string; publicKey: string; // pg-hex-encoded bytea } export interface DeviceApprovalListenerCtx { ownUserId: string; ownDeviceId: string; // Lazy getter so we never hold the privkey in memory for longer than the // approve action that needs it. Returns null if the key isn't loadable // (e.g. fresh-restored device that hasn't unsealed yet). getPriv: () => Promise; } const APPROVED_KEY = 'chatapp.approvedDeviceIds'; const DISMISSED_KEY = 'chatapp.dismissedDeviceIds'; // In-process state. Module-level so the banner component and the listener // share one source of truth without prop-drilling through context. let pending: PendingApproval[] = []; const subscribers = new Set<(list: PendingApproval[]) => void>(); let listenerCtx: DeviceApprovalListenerCtx | null = null; function readIdSet(key: string): Set { try { const raw = window.localStorage.getItem(key); if (!raw) return new Set(); const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return new Set(); return new Set(parsed.filter((v): v is string => typeof v === 'string')); } catch { return new Set(); } } function writeIdSet(key: string, set: Set): void { try { window.localStorage.setItem(key, JSON.stringify([...set])); } catch { /* storage unavailable — non-fatal */ } } function persistApproved(deviceId: string): void { const s = readIdSet(APPROVED_KEY); s.add(deviceId); writeIdSet(APPROVED_KEY, s); } function persistDismissed(deviceId: string): void { const s = readIdSet(DISMISSED_KEY); s.add(deviceId); writeIdSet(DISMISSED_KEY, s); } function notify(): void { const snapshot = [...pending]; for (const cb of subscribers) { try { cb(snapshot); } catch (err) { console.warn('deviceApproval: subscriber threw', err); } } } export function getPendingApprovals(): PendingApproval[] { return [...pending]; } export function subscribePendingApprovals( cb: (list: PendingApproval[]) => void, ): () => void { subscribers.add(cb); // Fire once with current state so the consumer can initialise without // waiting for the next change. try { cb([...pending]); } catch (err) { console.warn('deviceApproval: initial subscriber call threw', err); } return () => { subscribers.delete(cb); }; } function shouldSurface(deviceId: string, ownDeviceId: string): boolean { if (deviceId === ownDeviceId) return false; const approved = readIdSet(APPROVED_KEY); if (approved.has(deviceId)) return false; const dismissed = readIdSet(DISMISSED_KEY); if (dismissed.has(deviceId)) return false; return true; } interface DeviceRowLite { id: string; user_id: string; name: string; platform: DevicePlatform | string; created_at: string; public_key: string; } function rowToPending(row: DeviceRowLite): PendingApproval { return { deviceId: row.id, userId: row.user_id, name: row.name, platform: row.platform, createdAt: row.created_at, publicKey: row.public_key, }; } function upsertPending(req: PendingApproval): void { if (pending.some((p) => p.deviceId === req.deviceId)) return; pending = [...pending, req]; notify(); } function removePending(deviceId: string): void { const next = pending.filter((p) => p.deviceId !== deviceId); if (next.length === pending.length) return; pending = next; notify(); } async function loadInitialPending(ctx: DeviceApprovalListenerCtx): Promise { const { data, error } = await supabase .from('devices') .select('id, user_id, name, platform, created_at, public_key') .eq('user_id', ctx.ownUserId); if (error) { console.warn('deviceApproval: initial devices lookup failed', error); return; } const rows = (data ?? []) as DeviceRowLite[]; // Pre-filter: anything already in approved/dismissed sets, plus own // device, never surfaces. const candidates = rows.filter((r) => shouldSurface(r.id, ctx.ownDeviceId)); if (candidates.length === 0) return; // Semantic skip: any candidate that already has a `conversation_keys` // wrap somewhere is by definition legit — it was either auto-shared // back when that path was enabled (pre-0.16.2) or explicitly approved // earlier. Surfacing it now would just nag the user about something // already taken care of. Bulk query against `conversation_keys` for // all candidate device IDs at once; cheap, avoids N+1. const candidateIds = candidates.map((c) => c.id); const wrappedIds = new Set(); try { const { data: keyRows, error: kErr } = await ( supabase as unknown as { from: (t: string) => { select: (cols: string) => { in: ( col: string, vals: string[], ) => Promise<{ data: { recipient_device_id: string }[] | null; error: unknown }>; }; }; } ) .from('conversation_keys') .select('recipient_device_id') .in('recipient_device_id', candidateIds); if (!kErr && keyRows) { for (const r of keyRows) wrappedIds.add(r.recipient_device_id); } } catch (err) { console.warn('deviceApproval: conv-key existence probe failed', err); } // Defense in depth: also keep the created_at guard. Devices older than // own can't reasonably need approval — when own came online a fanout // pass already covered them. Helps when the conv_keys probe returns // partial data due to RLS. const own = rows.find((r) => r.id === ctx.ownDeviceId); const ownCreatedAt = own ? Date.parse(own.created_at) : Number.NEGATIVE_INFINITY; for (const row of candidates) { if (wrappedIds.has(row.id)) { persistApproved(row.id); continue; } const rowCreatedAt = Date.parse(row.created_at); if (Number.isFinite(rowCreatedAt) && rowCreatedAt <= ownCreatedAt) { persistApproved(row.id); continue; } upsertPending(rowToPending(row)); } } // Starts the approval listener for the given user/device. Returns an // unsubscribe function — call it on shell unmount to tear down the realtime // channel and clear in-memory state. export function startDeviceApprovalListener( ctx: DeviceApprovalListenerCtx, ): () => void { listenerCtx = ctx; void loadInitialPending(ctx); const channel = supabase .channel(`device-approval:${ctx.ownUserId}`) .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'devices', filter: `user_id=eq.${ctx.ownUserId}`, }, (payload: { new: Record }) => { const row = payload.new as unknown as DeviceRowLite; if (!row?.id) return; if (!shouldSurface(row.id, ctx.ownDeviceId)) return; upsertPending(rowToPending(row)); }, ) .subscribe(); return () => { void supabase.removeChannel(channel).catch(() => { /* ignore — channel might already be gone */ }); pending = []; listenerCtx = null; notify(); }; } // Approves a pending device: walks every conversation the current user is // in and writes a conv-key bundle for the new device. On success the request // is removed from the pending list and the deviceId is persisted in // localStorage so a reload doesn't re-prompt. export async function approveDevice(req: PendingApproval): Promise { const ctx = listenerCtx; if (!ctx) { throw new Error('deviceApproval: listener not started'); } if (req.userId !== ctx.ownUserId) { // Phase 1 only handles same-user approvals (own new device). Friend-side // approval is a later phase. throw new Error('deviceApproval: cross-user approval not supported yet'); } const priv = await ctx.getPriv(); if (!priv) { throw new Error('deviceApproval: own private key unavailable'); } const sync: SyncCtx = { myUserId: ctx.ownUserId, myDeviceId: ctx.ownDeviceId, priv, }; try { await wrapForOneDevice(sync, req.deviceId, req.userId, req.publicKey); } finally { // Wipe the priv copy we asked for. The original lives in the secret // store; this is the transient working copy. for (let i = 0; i < priv.length; i++) priv[i] = 0; } persistApproved(req.deviceId); removePending(req.deviceId); } // Denies a pending device: just remembers the deviceId in the dismissed-set // and removes the request. No server-side change — the new device simply // stays without any conv-key wraps until the user changes their mind (e.g. // from a settings screen later). export function denyDevice(deviceId: string): void { persistDismissed(deviceId); removePending(deviceId); } // Bulk-deny: persists every currently-pending deviceId into the dismissed // set and clears the in-memory list in one notify. Useful when a user has // accumulated stale entries from old test devices / migrations. export function denyAllPending(): void { if (pending.length === 0) return; const dismissed = readIdSet(DISMISSED_KEY); for (const p of pending) dismissed.add(p.deviceId); writeIdSet(DISMISSED_KEY, dismissed); pending = []; notify(); }