From 375d28efa5b50d2f37b2af8325cf37b97c1a5b33 Mon Sep 17 00:00:00 2001 From: byGalax Date: Wed, 13 May 2026 23:57:18 +0200 Subject: [PATCH] feat(mobile): AuthProvider + crypto-backend registration; drop sharedSmoke canary --- apps/mobile/app/_layout.tsx | 33 +++++---- apps/mobile/lib/authContext.tsx | 124 ++++++++++++++++++++++++++++++++ apps/mobile/lib/sharedSmoke.ts | 14 ---- 3 files changed, 140 insertions(+), 31 deletions(-) create mode 100644 apps/mobile/lib/authContext.tsx delete mode 100644 apps/mobile/lib/sharedSmoke.ts diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index c4c0969..5406583 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -1,33 +1,32 @@ +import { crypto } from '@chat-app/shared'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { ErrorBoundary } from '../components/ErrorBoundary'; +import { AuthProvider } from '../lib/authContext'; +import { createLibsodiumBackend } from '../lib/cryptoBackend'; -// Force-resolve @chat-app/shared at boot so Metro packaging issues -// surface during the very first dev-client load instead of mid-Phase-1. -// Reference the export so tree-shaking can't drop it. -import { sharedSmoke } from '../lib/sharedSmoke'; -void sharedSmoke; +// Register the crypto backend exactly once, before any code that calls +// crypto.getCryptoBackend(). Doing it at module load (not inside the +// component) avoids a race where a child component renders before the +// effect fires. +crypto.setCryptoBackend(createLibsodiumBackend()); -// Root layout for every Expo Router screen. Provider stack order: -// GestureHandlerRootView — required by gesture-driven libs (BottomSheet, -// swipeable rows, drawer nav). Must be the -// outermost so gesture state is global. -// SafeAreaProvider — feeds notch / status-bar insets to children. -// ErrorBoundary — last line of defence for render errors. -// Stack — Expo Router's screen registry. export default function RootLayout() { return ( - - - - - + + + + + + + + diff --git a/apps/mobile/lib/authContext.tsx b/apps/mobile/lib/authContext.tsx new file mode 100644 index 0000000..243d19e --- /dev/null +++ b/apps/mobile/lib/authContext.tsx @@ -0,0 +1,124 @@ +import type { Session, User } from '@supabase/supabase-js'; +import { auth, crypto } from '@chat-app/shared'; +import type { DeviceRecord } from '@chat-app/shared/auth'; +import { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import { Platform } from 'react-native'; + +import { secretStore } from './secretStore'; +import { supabase } from './supabase'; + +// Locally-stored secrets keyed by stable names. Mirrors the desktop +// convention so the migration tests (later) can compare snapshots. +const KEY_DEVICE_ID = 'device.id'; +const KEY_DEVICE_PRIVKEY = 'device.privateKey'; + +interface AuthContextValue { + session: Session | null; + user: User | null; + device: DeviceRecord | null; + ownPrivateKey: Uint8Array | null; + loading: boolean; + signOut: () => Promise; +} + +const Ctx = createContext(null); + +export function useAuth(): AuthContextValue { + const v = useContext(Ctx); + if (!v) throw new Error('useAuth() called outside '); + return v; +} + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [session, setSession] = useState(null); + const [device, setDevice] = useState(null); + const [ownPrivateKey, setOwnPrivateKey] = useState(null); + const [loading, setLoading] = useState(true); + + // Resolve or create the device record for this install given an active + // session. Stores the private key in expo-secure-store on first run. + const ensureDevice = useCallback(async (currentSession: Session): Promise => { + const userId = currentSession.user.id; + const savedDeviceId = await secretStore.getSecret(KEY_DEVICE_ID); + const savedPrivKey = await secretStore.getSecret(KEY_DEVICE_PRIVKEY); + + if (savedDeviceId && savedPrivKey) { + const devices = await auth.listOwnDevices(supabase); + const deviceIdStr = new TextDecoder().decode(savedDeviceId); + const match = devices.find((d) => d.id === deviceIdStr); + if (match) { + setDevice(match); + setOwnPrivateKey(savedPrivKey); + return; + } + // Stored id no longer matches any device on the server (revoked, + // wiped). Fall through to register a fresh one. + } + + const backend = crypto.getCryptoBackend(); + const kp = backend.generateKeyPair(); + const platform = Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'linux'; + const record = await auth.registerDevice(supabase, { + name: 'Netralax Mobile (' + Platform.OS + ')', + platform, + publicKey: kp.publicKey, + }); + await secretStore.setSecret(KEY_DEVICE_ID, new TextEncoder().encode(record.id)); + await secretStore.setSecret(KEY_DEVICE_PRIVKEY, kp.privateKey); + setDevice(record); + setOwnPrivateKey(kp.privateKey); + void userId; + }, []); + + useEffect(() => { + let cancelled = false; + void (async () => { + const { data } = await supabase.auth.getSession(); + if (cancelled) return; + setSession(data.session); + if (data.session) { + try { + await ensureDevice(data.session); + } catch (err) { + console.warn('[auth] ensureDevice failed', err); + } + } + setLoading(false); + })(); + + const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => { + setSession(nextSession); + if (!nextSession) { + setDevice(null); + setOwnPrivateKey(null); + } else { + void ensureDevice(nextSession).catch((err) => + console.warn('[auth] ensureDevice (state change) failed', err), + ); + } + }); + + return () => { + cancelled = true; + sub.subscription.unsubscribe(); + }; + }, [ensureDevice]); + + const signOut = useCallback(async (): Promise => { + await supabase.auth.signOut(); + await secretStore.removeSecret(KEY_DEVICE_ID); + await secretStore.removeSecret(KEY_DEVICE_PRIVKEY); + setDevice(null); + setOwnPrivateKey(null); + }, []); + + const value: AuthContextValue = { + session, + user: session?.user ?? null, + device, + ownPrivateKey, + loading, + signOut, + }; + return {children}; +} diff --git a/apps/mobile/lib/sharedSmoke.ts b/apps/mobile/lib/sharedSmoke.ts deleted file mode 100644 index c736362..0000000 --- a/apps/mobile/lib/sharedSmoke.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Runtime canary import from @chat-app/shared. Phase 0's only goal here -// is to prove that Metro can resolve the workspace package and that -// nothing in shared/crypto pulls in an RN-incompatible module path. -// -// `import type` would be erased by the TypeScript compiler before -// reaching Metro, so we deliberately import a runtime symbol — -// `crypto.setCryptoBackend` — and stash it in an exported reference. -// We never call it here; Phase 1's real adapter does that after -// constructing a CryptoBackend wrapping react-native-libsodium. -// -// This file is deleted in Phase 1 once the real adapter lands. -import { crypto } from '@chat-app/shared'; - -export const sharedSmoke = { register: crypto.setCryptoBackend };