diff --git a/apps/desktop/src/components/AppShell.tsx b/apps/desktop/src/components/AppShell.tsx
index 97ec6fc..0c05ab6 100644
--- a/apps/desktop/src/components/AppShell.tsx
+++ b/apps/desktop/src/components/AppShell.tsx
@@ -4,6 +4,7 @@ import { Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { startConversationKeySync } from '../lib/conversationKeySync';
import { startDeviceApprovalListener } from '../lib/deviceApproval';
+import { ensureInstallId } from '../lib/installId';
import { ensureNotificationPermission } from '../lib/osNotify';
import { cachedUserKey } from '../lib/userIdentity';
import { BackupPromptBanner } from './BackupPromptBanner';
@@ -12,7 +13,7 @@ import { DeviceApprovalBanner } from './DeviceApprovalBanner';
import { Sidebar } from './Sidebar';
export function AppShell() {
- const { session, device } = useAuth();
+ const { session } = useAuth();
useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents
// re-asking if the user already responded this session.
@@ -20,20 +21,25 @@ export function AppShell() {
}, []);
useEffect(() => {
- if (!session?.user.id || !device?.id) return;
+ if (!session?.user.id) return;
const userId = session.user.id;
- const deviceId = device.id;
- const stopKeySync = startConversationKeySync(userId, deviceId);
+ // Per-install id replaces the per-device record now that crypto is
+ // user-keyed. The legacy conv-key-sync and device-approval subsystems
+ // still take a "deviceId" argument for telemetry / row identity; passing
+ // the install id keeps those wires intact until they are themselves
+ // reworked in later tasks (Task 16/17/19 territory).
+ const installId = ensureInstallId();
+ const stopKeySync = startConversationKeySync(userId, installId);
const stopApproval = startDeviceApprovalListener({
ownUserId: userId,
- ownDeviceId: deviceId,
+ ownDeviceId: installId,
getPriv: () => cachedUserKey(userId),
});
return () => {
stopKeySync();
stopApproval();
};
- }, [session?.user.id, device?.id]);
+ }, [session?.user.id]);
return (
diff --git a/apps/desktop/src/components/ForwardDialog.tsx b/apps/desktop/src/components/ForwardDialog.tsx
index 082ab3c..74156ae 100644
--- a/apps/desktop/src/components/ForwardDialog.tsx
+++ b/apps/desktop/src/components/ForwardDialog.tsx
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext';
+import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { Avatar } from './Avatar';
@@ -32,7 +33,7 @@ interface Props {
// preview hints at the dropped attachment.
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
const { t } = useTranslation(['app', 'errors']);
- const { session, device } = useAuth();
+ const { session } = useAuth();
const { conversations } = useConversationsContext();
const [selected, setSelected] = useState
>(new Set());
@@ -83,7 +84,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
}
async function handleSend() {
- if (!session?.user.id || !device?.id || !message) return;
+ if (!session?.user.id || !message) return;
if (selected.size === 0) return;
setBusy(true);
setError(null);
@@ -137,7 +138,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
conversationId: convId,
plaintext: text,
senderUserId: session.user.id,
- senderDeviceId: device.id,
+ senderDeviceId: ensureInstallId(),
senderPrivateKey: priv,
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
});
diff --git a/apps/desktop/src/components/MessageBubble.tsx b/apps/desktop/src/components/MessageBubble.tsx
index c48aaa1..5f2a234 100644
--- a/apps/desktop/src/components/MessageBubble.tsx
+++ b/apps/desktop/src/components/MessageBubble.tsx
@@ -10,6 +10,7 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
+import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import type { AggregatedReaction } from '../lib/useMessageReactions';
@@ -97,7 +98,7 @@ export function MessageBubble({
highlighted = false,
}: Props) {
const { t } = useTranslation(['app']);
- const { session, device } = useAuth();
+ const { session } = useAuth();
const parsed = parseMessagePayload(message.plaintext);
const initialText = parsed.kind === 'text' ? parsed.text : '';
@@ -190,7 +191,7 @@ export function MessageBubble({
}, [pickerOpen]);
const handleEditSave = useCallback(async () => {
- if (!session || !device) return;
+ if (!session) return;
const trimmed = editText.trim();
if (!trimmed || trimmed === bodyText) {
setEditing(false);
@@ -208,7 +209,7 @@ export function MessageBubble({
conversationId,
newPlaintext: trimmed,
senderUserId: session.user.id,
- senderDeviceId: device.id,
+ senderDeviceId: ensureInstallId(),
senderPrivateKey: priv,
});
setEditing(false);
@@ -224,7 +225,7 @@ export function MessageBubble({
} finally {
setBusy(false);
}
- }, [editText, message.id, message.plaintext, conversationId, session, device, t]);
+ }, [editText, message.id, message.plaintext, conversationId, session, t]);
const handleDelete = useCallback(async () => {
if (busy) return;
diff --git a/apps/desktop/src/components/guards.tsx b/apps/desktop/src/components/guards.tsx
index c6d386b..f0a61ce 100644
--- a/apps/desktop/src/components/guards.tsx
+++ b/apps/desktop/src/components/guards.tsx
@@ -23,11 +23,14 @@ export function RequireAuth() {
return ;
}
-// Forces a registered device on this install. Sends to /device otherwise.
+// Forces a usable per-user encrypted key blob on this install. Sends to
+// /device (the setup/unlock page) when the blob is missing or locked.
export function RequireDevice() {
- const { device, deviceLookupDone } = useAuth();
- if (!deviceLookupDone) return ;
- if (!device) return ;
+ const { userKeyState } = useAuth();
+ if (userKeyState.status === 'loading') return ;
+ if (userKeyState.status === 'needs-setup' || userKeyState.status === 'needs-unlock') {
+ return ;
+ }
return ;
}
diff --git a/apps/desktop/src/context/AuthContext.tsx b/apps/desktop/src/context/AuthContext.tsx
index 9a3ae79..0d00a4a 100644
--- a/apps/desktop/src/context/AuthContext.tsx
+++ b/apps/desktop/src/context/AuthContext.tsx
@@ -1,9 +1,8 @@
import {
- type DeviceRecord,
+ fetchUserKeyBlob,
getOwnProfile,
signOut as supabaseSignOut,
type Profile,
- touchDeviceLastSeen,
updateOwnProfile,
} from '@chat-app/shared/auth';
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
@@ -20,23 +19,34 @@ import {
} from 'react';
import { useTranslation } from 'react-i18next';
-import { findExistingDevice } from '../lib/device';
-import { PRESENCE_HEARTBEAT_MS } from '../lib/presence';
+import { ensureInstallId } from '../lib/installId';
import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
+import { cachedUserKey } from '../lib/userIdentity';
import { registerWebPush } from '../lib/webPush';
+// Discriminated union describing the per-user encrypted key blob lifecycle:
+//
+// loading — initial state, or refresh in flight
+// needs-setup — no row exists on Supabase; user must pick a PIN
+// needs-unlock — row exists but local cache empty; PIN (or recovery code)
+// required. `lockedUntil` non-null means the server-side
+// rate limiter is currently rejecting attempts.
+// unlocked — private key is in the local secret store and ready to use
+export type UserKeyState =
+ | { status: 'loading' }
+ | { status: 'needs-setup' }
+ | { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
+ | { status: 'unlocked' };
+
interface AuthContextValue {
session: Session | null;
profile: Profile | null;
- device: DeviceRecord | null;
+ userKeyState: UserKeyState;
// null while we're still resolving the very first auth state.
ready: boolean;
- // null until a device lookup has finished for the current session.
- deviceLookupDone: boolean;
refreshProfile: () => Promise;
- refreshDevice: () => Promise;
- setDevice: (device: DeviceRecord | null) => void;
+ refreshUserKeyState: () => Promise;
signOut: () => Promise;
}
@@ -47,8 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState(null);
const [ready, setReady] = useState(false);
const [profile, setProfile] = useState(null);
- const [device, setDevice] = useState(null);
- const [deviceLookupDone, setDeviceLookupDone] = useState(false);
+ const [userKeyState, setUserKeyState] = useState({ status: 'loading' });
const autoOnlineUserRef = useRef(null);
// Initial session + auth subscription. We verify the cached JWT against the
@@ -112,41 +121,72 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [session, i18n.resolvedLanguage]);
- const refreshDevice = useCallback(async () => {
+ // Resolves the current state of the per-user encrypted key blob:
+ // 1. local cache hit → 'unlocked'
+ // 2. no remote row → 'needs-setup'
+ // 3. server rate-limit → 'needs-unlock' with lockedUntil set
+ // 4. otherwise → 'needs-unlock'; hasRecovery reflects whether a
+ // recovery-code blob is present so the UI can
+ // conditionally offer the recovery affordance
+ const refreshUserKeyState = useCallback(async () => {
if (!session) {
- setDevice(null);
- setDeviceLookupDone(false);
+ setUserKeyState({ status: 'loading' });
return;
}
- setDeviceLookupDone(false);
- const found = await findExistingDevice(session.user.id);
- setDevice(found);
- setDeviceLookupDone(true);
+ setUserKeyState({ status: 'loading' });
+ const cached = await cachedUserKey(session.user.id);
+ if (cached) {
+ setUserKeyState({ status: 'unlocked' });
+ return;
+ }
+ const blob = await fetchUserKeyBlob(supabase, session.user.id);
+ if (!blob || !blob.exists) {
+ setUserKeyState({ status: 'needs-setup' });
+ return;
+ }
+ if (blob.locked) {
+ setUserKeyState({
+ status: 'needs-unlock',
+ lockedUntil: blob.lockedUntil,
+ hasRecovery: false,
+ });
+ return;
+ }
+ setUserKeyState({
+ status: 'needs-unlock',
+ lockedUntil: null,
+ hasRecovery: blob.recoverySealedPrivateKey !== null,
+ });
}, [session]);
- // Re-pull profile + device whenever session flips.
+ // Re-pull profile + user-key state whenever session flips.
useEffect(() => {
if (!session) {
setProfile(null);
- setDevice(null);
- setDeviceLookupDone(false);
+ setUserKeyState({ status: 'loading' });
return;
}
void refreshProfile().catch((err: unknown) => {
console.error('refreshProfile failed', err);
});
- void refreshDevice().catch((err: unknown) => {
- console.error('refreshDevice failed', err);
- setDeviceLookupDone(true);
+ void refreshUserKeyState().catch((err: unknown) => {
+ console.error('refreshUserKeyState failed', err);
+ // Treat an unrecoverable lookup error as "needs-setup" so the UI at
+ // least drives the user toward the setup/unlock page rather than
+ // hanging forever on the spinner.
+ setUserKeyState({ status: 'needs-setup' });
});
- }, [session, refreshProfile, refreshDevice]);
+ }, [session, refreshProfile, refreshUserKeyState]);
- // Best-effort web-push registration once we know the device id. No-op on
- // Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset.
+ // Best-effort web-push registration once we have a session. Keyed by an
+ // install-id (localStorage UUID) since there's no longer a per-device
+ // crypto record to key by. No-op on Tauri (uses native notifications) or
+ // when VITE_VAPID_PUBLIC_KEY is unset.
useEffect(() => {
- if (!device?.id) return;
- void registerWebPush(device.id);
- }, [device?.id]);
+ if (!session) return;
+ const installId = ensureInstallId();
+ void registerWebPush(installId);
+ }, [session]);
// Auto online/offline transition.
//
@@ -188,32 +228,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, [session, profile, refreshProfile]);
- useEffect(() => {
- if (!session || !device?.id) return;
-
- const touch = () => {
- void touchDeviceLastSeen(supabase, device.id).catch((err: unknown) => {
- console.warn('presence heartbeat failed', err);
- });
- };
- const touchWhenVisible = () => {
- if (document.visibilityState === 'visible') touch();
- };
-
- touch();
- const heartbeat = window.setInterval(touch, PRESENCE_HEARTBEAT_MS);
- window.addEventListener('focus', touch);
- window.addEventListener('online', touch);
- document.addEventListener('visibilitychange', touchWhenVisible);
-
- return () => {
- window.clearInterval(heartbeat);
- window.removeEventListener('focus', touch);
- window.removeEventListener('online', touch);
- document.removeEventListener('visibilitychange', touchWhenVisible);
- };
- }, [session, device?.id]);
-
const signOut = useCallback(async () => {
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
console.warn('offline update before sign-out failed', err);
@@ -225,15 +239,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
() => ({
session,
profile,
- device,
+ userKeyState,
ready,
- deviceLookupDone,
refreshProfile,
- refreshDevice,
- setDevice,
+ refreshUserKeyState,
signOut,
}),
- [session, profile, device, ready, deviceLookupDone, refreshProfile, refreshDevice, signOut],
+ [session, profile, userKeyState, ready, refreshProfile, refreshUserKeyState, signOut],
);
return {children};
diff --git a/apps/desktop/src/context/CallContext.tsx b/apps/desktop/src/context/CallContext.tsx
index 80bea6c..39a3ef9 100644
--- a/apps/desktop/src/context/CallContext.tsx
+++ b/apps/desktop/src/context/CallContext.tsx
@@ -113,6 +113,7 @@ import {
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
+import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { setWindowFullscreen } from '../lib/windowFullscreen';
@@ -324,7 +325,7 @@ function newCallId(): string {
}
export function CallProvider({ children }: { children: ReactNode }) {
- const { session, device, profile } = useAuth();
+ const { session, profile } = useAuth();
const { conversations } = useConversationsContext();
const myId = session?.user.id;
@@ -445,7 +446,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
mediaKind: CallKind,
durationSec: number,
) => {
- if (!session?.user.id || !device?.id) return;
+ if (!session?.user.id) return;
try {
const priv = await cachedUserKey(session.user.id);
if (!priv) return;
@@ -461,14 +462,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
conversationId,
plaintext: payload,
senderUserId: session.user.id,
- senderDeviceId: device.id,
+ senderDeviceId: ensureInstallId(),
senderPrivateKey: priv,
});
} catch (err: unknown) {
console.error('emitCallEvent failed', err);
}
},
- [session?.user.id, device?.id],
+ [session?.user.id],
);
const clearRingTimer = useCallback(() => {
diff --git a/apps/desktop/src/lib/installId.ts b/apps/desktop/src/lib/installId.ts
new file mode 100644
index 0000000..83e12d9
--- /dev/null
+++ b/apps/desktop/src/lib/installId.ts
@@ -0,0 +1,19 @@
+// Per-install browser identifier. Replaces the legacy per-device crypto
+// identity for non-crypto bookkeeping (push tokens, message sender_device_id
+// metadata) where we just need a stable, opaque id for this browser/install.
+//
+// The user-key model has no device-bound crypto; the `devices` table and
+// `messages.sender_device_id` column still exist but they're now plain
+// telemetry. Using a localStorage UUID keeps the column populated without
+// any of the old key-management overhead.
+
+const KEY = 'chatapp.installId';
+
+export function ensureInstallId(): string {
+ let id = window.localStorage.getItem(KEY);
+ if (!id) {
+ id = crypto.randomUUID();
+ window.localStorage.setItem(KEY, id);
+ }
+ return id;
+}
diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx
index 02e2d52..ca47a37 100644
--- a/apps/desktop/src/pages/ConversationPage.tsx
+++ b/apps/desktop/src/pages/ConversationPage.tsx
@@ -37,6 +37,7 @@ import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
import { compressImages } from '../lib/imageCompress';
+import { ensureInstallId } from '../lib/installId';
import { searchCachedMessages } from '../lib/messageCache';
import type { OutboxItem } from '../lib/messageOutbox';
import { useConversationMessages } from '../lib/useConversationMessages';
@@ -61,7 +62,7 @@ const scrollPositions = new Map();
- const { session, device } = useAuth();
+ const { session } = useAuth();
const { conversations, setActiveConversation, markRead, unread } = useConversationsContext();
const conversation = useMemo(
@@ -75,7 +76,7 @@ export function ConversationPage() {
useConversationMessages({
conversationId: id,
userId: session?.user.id,
- deviceId: device?.id,
+ deviceId: ensureInstallId(),
});
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
const {