feat(mobile): AuthProvider + crypto-backend registration; drop sharedSmoke canary
This commit is contained in:
+11
-12
@@ -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 (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<StatusBar style="auto" />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="(app)" />
|
||||
<Stack.Screen name="auth/callback" />
|
||||
</Stack>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
const Ctx = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const v = useContext(Ctx);
|
||||
if (!v) throw new Error('useAuth() called outside <AuthProvider>');
|
||||
return v;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [device, setDevice] = useState<DeviceRecord | null>(null);
|
||||
const [ownPrivateKey, setOwnPrivateKey] = useState<Uint8Array | null>(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<void> => {
|
||||
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<void> => {
|
||||
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 <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user