diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example new file mode 100644 index 0000000..bb768a0 --- /dev/null +++ b/apps/mobile/.env.example @@ -0,0 +1,15 @@ +# Copy to .env.local and fill in. See docs/auth.md for where each value comes from. +# +# EXPO_PUBLIC_ variables are compiled into the app bundle and are not secret. +# The anon key is designed to be public and is protected by row-level security; +# an OAuth client ID is public by definition. Never put a service-role key here. +# +# Leaving these blank is a supported build: notes and reading the feed work, +# and the sign-in screen says it is not set up. + +EXPO_PUBLIC_SUPABASE_URL= +EXPO_PUBLIC_SUPABASE_ANON_KEY= + +# The *web* OAuth client ID, on Android too. The Android client is matched by +# package name and signing fingerprint and is never named here. +EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID= diff --git a/apps/mobile/app.json b/apps/mobile/app.json index c8a9081..c84e694 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -10,7 +10,8 @@ "ios": { "icon": "./assets/expo.icon", "bundleIdentifier": "com.zacktiger.dailynote", - "supportsTablet": true + "supportsTablet": true, + "usesAppleSignIn": true }, "android": { "package": "com.zacktiger.dailynote", @@ -48,7 +49,10 @@ { "faceIDPermission": "DailyNote uses Face ID to open notes you have locked." } - ] + ], + "@react-native-google-signin/google-signin", + "expo-apple-authentication", + "expo-web-browser" ], "experiments": { "typedRoutes": true, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5e26c2d..0febaa5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -18,7 +18,11 @@ "@expo-google-fonts/newsreader": "^0.4.1", "@expo/ui": "~57.0.8", "@expo/vector-icons": "^15.0.2", + "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-google-signin/google-signin": "^16.1.4", + "@supabase/supabase-js": "^2.112.0", "expo": "~57.0.8", + "expo-apple-authentication": "~57.0.1", "expo-constants": "~57.0.7", "expo-crypto": "~57.0.1", "expo-file-system": "~57.0.1", @@ -33,6 +37,7 @@ "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.2", + "expo-web-browser": "~57.0.2", "nativewind": "^4.2.1", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/apps/mobile/src/app/(tabs)/social.tsx b/apps/mobile/src/app/(tabs)/social.tsx index 42f36bc..344e1d4 100644 --- a/apps/mobile/src/app/(tabs)/social.tsx +++ b/apps/mobile/src/app/(tabs)/social.tsx @@ -1,5 +1,5 @@ import { composeFeed, type FeedItem } from '@dailynote/core'; -import { useRouter } from 'expo-router'; +import { useRouter, type Href } from 'expo-router'; import { useCallback, useMemo, useState } from 'react'; import { Pressable, Text, View } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; @@ -28,10 +28,20 @@ import { useTheme } from '@/theme'; export default function Feed() { const theme = useTheme(); const router = useRouter(); - const { me, items, following, blocked, loading, setLiked } = useSocial(); + const { me, needed, items, following, blocked, loading, setLiked } = useSocial(); const [scope, setScope] = useState<'following' | 'everyone'>('everyone'); + /** + * The screen standing between the reader and publishing, or null. + * + * Sign-in and the handle claim are two steps, and this names whichever is + * outstanding so nobody is sent to one they have already satisfied. Reading + * never consults it. + */ + const gate: Href | null = + needed === 'sign-in' ? '/sign-in' : needed === 'handle' ? '/handle' : null; + const feed = useMemo( () => composeFeed(items, { @@ -62,9 +72,9 @@ export default function Feed() { accessibilityRole="button" accessibilityLabel={me === null ? 'Set up your profile' : 'Your profile'} onPress={() => - me === null - ? router.push('/handle') - : router.push({ pathname: '/profile/[handle]', params: { handle: me.handle } }) + router.push( + gate ?? { pathname: '/profile/[handle]', params: { handle: me?.handle ?? '' } }, + ) } className="h-11 w-11 items-center justify-center rounded-full active:opacity-50" > @@ -82,12 +92,7 @@ export default function Feed() { contentContainerClassName="pb-32" itemLayoutAnimation={motion.layout} ListHeaderComponent={ -
+
} ListEmptyComponent={loading ? null : } renderItem={({ item, index }) => ( @@ -105,7 +110,7 @@ export default function Feed() { accessibilityLabel="Write a post" onPress={() => { haptics.tap(); - router.push(me === null ? '/handle' : '/post/new'); + router.push(gate ?? '/post/new'); }} onPressIn={() => { fabPressed.value = withSpring(1, motion.SPRING); @@ -126,12 +131,12 @@ function Header({ scope, onScope, count, - claimed, + needed, }: { scope: 'following' | 'everyone'; onScope: (next: 'following' | 'everyone') => void; count: number; - claimed: boolean; + needed: 'sign-in' | 'handle' | null; }) { const router = useRouter(); const theme = useTheme(); @@ -145,21 +150,23 @@ function Header({ {count === 1 ? '1 thread' : `${count} threads`} - {!claimed ? ( - // Shown until a handle exists rather than blocking the screen: reading - // the feed needs no account, and being asked to sign up before you can - // see anything is how a social tab teaches people to avoid it. + {needed !== null ? ( + // A prompt, not a wall. Reading the feed needs no account at all, and + // demanding a sign-up before you can see anything is how a social tab + // teaches people to avoid it. router.push('/handle')} + onPress={() => router.push(needed === 'sign-in' ? '/sign-in' : '/handle')} className="mx-4 mt-4 flex-row items-center gap-3 rounded-2xl bg-card px-4 py-3.5 active:opacity-70 dark:bg-card-dark" > - Pick a handle to post + {needed === 'sign-in' ? 'Sign in to publish' : 'Pick a handle to post'} - Reading needs nothing. Publishing needs a name. + {needed === 'sign-in' + ? 'Reading needs nothing. Your notes need nothing either.' + : 'One more step, and it is the only one that shows.'} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 6e81a56..40cadd7 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -16,6 +16,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { migrate } from '@/db/migrations'; +import { AuthProvider } from '@/store/auth-store'; import { NotebooksProvider } from '@/store/notebooks-store'; import { NotesProvider } from '@/store/notes-store'; import { SocialProvider } from '@/store/social-store'; @@ -67,37 +68,57 @@ export default function RootLayout() { {/* Inside NotesProvider, not beside it: publishing is an operation on a note, so the social store reads the notes - store rather than keeping a second copy of the same rows. */} - - - - - - - - - - - - - - + store rather than keeping a second copy of the same rows. + Auth wraps social because a profile is keyed to an account, + and nothing in the notes half reads either of them. */} + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/src/app/handle.tsx b/apps/mobile/src/app/handle.tsx index d4c1a0e..de1c2cf 100644 --- a/apps/mobile/src/app/handle.tsx +++ b/apps/mobile/src/app/handle.tsx @@ -16,6 +16,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Icon } from '@/components/icon'; import { haptics } from '@/lib/haptics'; import { useGoBack } from '@/lib/nav'; +import { useAuth } from '@/store/auth-store'; import { useSocial } from '@/store/social-store'; import { useTheme } from '@/theme'; @@ -31,6 +32,7 @@ export default function ClaimHandle() { const router = useRouter(); const goBack = useGoBack(); const { me, claim } = useSocial(); + const { userId } = useAuth(); const [handle, setHandle] = useState(''); const [name, setName] = useState(''); @@ -63,6 +65,23 @@ export default function ClaimHandle() { })(); }, [ready, claim, normalized, name, router]); + // Reachable by deep link, or by signing out with this screen still on the + // stack. A handle belongs to an account, so there is nothing to claim yet. + if (userId === null) { + return ( + + Sign in first. + router.replace('/sign-in')} + className="mt-4 active:opacity-60" + accessibilityRole="button" + > + Sign in + + + ); + } + if (me !== null) { return ( diff --git a/apps/mobile/src/app/note/[id].tsx b/apps/mobile/src/app/note/[id].tsx index 2208fe1..29c97ea 100644 --- a/apps/mobile/src/app/note/[id].tsx +++ b/apps/mobile/src/app/note/[id].tsx @@ -256,11 +256,24 @@ function Editor({ note }: { note: Note | null }) { const problem = shareError(current); if (problem !== null) { + // Two of these are fixable in one tap, so they get a button rather + // than an instruction to go and find the right screen. + const route = + problem === 'Sign in to publish.' + ? '/sign-in' + : problem === 'Claim a handle first.' + ? '/handle' + : null; + Alert.alert( - 'Not this one', - problem === 'Claim a handle first.' - ? 'Pick a handle on the Feed tab before publishing anything.' - : problem, + route === null ? 'Not this one' : 'Almost', + problem, + route === null + ? undefined + : [ + { text: 'Not now', style: 'cancel' }, + { text: 'Continue', onPress: () => router.push(route) }, + ], ); return; } @@ -284,7 +297,7 @@ function Editor({ note }: { note: Note | null }) { ], ); })(); - }, [requireSaved, repo, shareError, publish]); + }, [requireSaved, repo, shareError, publish, router]); const confirmUnshare = useCallback(() => { setSheet(null); diff --git a/apps/mobile/src/app/profile/[handle].tsx b/apps/mobile/src/app/profile/[handle].tsx index b3e23fb..c173397 100644 --- a/apps/mobile/src/app/profile/[handle].tsx +++ b/apps/mobile/src/app/profile/[handle].tsx @@ -9,6 +9,7 @@ import { Icon, type IconName } from '@/components/icon'; import { Sheet } from '@/components/sheet'; import { haptics } from '@/lib/haptics'; import { useGoBack } from '@/lib/nav'; +import { useAuth } from '@/store/auth-store'; import { useSocial } from '@/store/social-store'; import { useTheme } from '@/theme'; @@ -27,6 +28,7 @@ export default function ProfileScreen() { const goBack = useGoBack(); const { me, items, following, blocked, repo, setFollowing, setBlocked, setLiked, updateMe } = useSocial(); + const { signOut } = useAuth(); const [profile, setProfile] = useState(null); const [counts, setCounts] = useState({ followers: 0, following: 0 }); @@ -171,7 +173,28 @@ export default function ProfileScreen() { setSheet(null)} title="Profile"> {mine ? ( - setSheet('edit')} /> + <> + setSheet('edit')} /> + { + setSheet(null); + Alert.alert('Sign out?', 'Your notes stay on this device either way.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Sign out', + style: 'destructive', + onPress: () => { + void signOut(); + goBack(); + }, + }, + ]); + }} + /> + ) : ( (null); + const [error, setError] = useState(null); + + /** + * Where to go once there is a session. + * + * `replace`, not `push`: coming back to a sign-in screen you have already + * satisfied, by way of the back gesture, is a dead end. + */ + const onward = useCallback(() => { + haptics.success(); + if (me === null) { + router.replace('/handle'); + } else { + goBack(); + } + }, [me, router, goBack]); + + const run = useCallback( + (which: 'google' | 'apple', attempt: () => Promise) => { + setError(null); + setBusy(which); + void (async () => { + const result = await attempt(); + setBusy(null); + if (result.ok) { + onward(); + } else if (!result.cancelled) { + setError(result.message); + } + })(); + }, + [onward], + ); + + const emailReady = EMAIL_PATTERN.test(email.trim()); + + const send = useCallback(() => { + if (!emailReady) return; + setError(null); + setBusy('email'); + void (async () => { + const result = await sendEmailCode(email); + setBusy(null); + if (result.ok) { + setSent(true); + } else if (!result.cancelled) { + setError(result.message); + } + })(); + }, [emailReady, email]); + + const verify = useCallback(() => { + if (code.trim().length < 6) return; + setError(null); + setBusy('email'); + void (async () => { + const result = await verifyEmailCode(email, code); + setBusy(null); + if (result.ok) { + onward(); + } else if (!result.cancelled) { + setError(result.message); + } + })(); + }, [code, email, onward]); + + if (userId !== null) { + return ( + + You are signed in. + + Go back + + + ); + } + + return ( + + + + + + + + + + + Sign in to publish + + + Your notes never needed an account and still do not. This is only for putting something + where other people can read it. + + + {!isAuthConfigured ? ( + + + Not set up in this build + + + No Supabase credentials were compiled in. Notes and reading the feed still work. + See docs/auth.md. + + + ) : null} + + {error !== null ? ( + + + {error} + + + ) : null} + + {canUseGoogle ? ( + run('google', signInWithGoogle)} + /> + ) : null} + + {canUseApple ? ( + // Apple's own button, not a lookalike: guideline 4.8 and the brand + // rules both require the real control at the real proportions. + run('apple', signInWithApple)} + /> + ) : null} + + {/* --- the email escape hatch --- */} + + + {sent ? 'Enter the code we sent' : 'Or use your email'} + + + {sent ? ( + <> + + + Sent to {email.trim()}. + + + + { + setSent(false); + setCode(''); + }} + accessibilityRole="button" + className="items-center pt-3 active:opacity-60" + > + + Use a different address + + + + ) : ( + <> + + + + Six digits, no password. Nothing to forget and nothing to leak. + + + )} + + + + + ); +} + +function ProviderButton({ + icon, + label, + busy, + disabled, + onPress, +}: { + icon: 'globe'; + label: string; + busy: boolean; + disabled: boolean; + onPress: () => void; +}) { + const theme = useTheme(); + return ( + + {busy ? ( + + ) : ( + <> + + {label} + + )} + + ); +} + +function PrimaryButton({ + label, + busy, + disabled, + onPress, +}: { + label: string; + busy: boolean; + disabled: boolean; + onPress: () => void; +}) { + const theme = useTheme(); + return ( + + {busy ? ( + + ) : ( + + {label} + + )} + + ); +} diff --git a/apps/mobile/src/db/migrations.ts b/apps/mobile/src/db/migrations.ts index 01c3c6b..0bcbc81 100644 --- a/apps/mobile/src/db/migrations.ts +++ b/apps/mobile/src/db/migrations.ts @@ -189,6 +189,28 @@ const MIGRATIONS: readonly ((db: SQLiteDatabase) => Promise)[] = [ create index follows_followee_idx on follows (followee_id); `); }, + + // v5 -- accounts. + // + // `social_identity` cached which local profile was "me". Real sign-in makes + // that a second answer to a question the session already answers, and the + // two could disagree after a token refresh or a sign-out elsewhere. The + // profile's primary key is now the account id, so "who am I" has exactly one + // source: the session. + // + // The table is dropped rather than backfilled. It only ever held a locally + // generated id that no account will ever match, and this ships before the + // first build anybody has installed. + async (db) => { + await db.execAsync(` + drop table if exists social_identity; + delete from profiles; + delete from feed_items; + delete from follows; + delete from reactions; + delete from blocks; + `); + }, ]; /** diff --git a/apps/mobile/src/db/social-repo.ts b/apps/mobile/src/db/social-repo.ts index 8f2fc70..6526138 100644 --- a/apps/mobile/src/db/social-repo.ts +++ b/apps/mobile/src/db/social-repo.ts @@ -3,6 +3,16 @@ import type { SQLiteDatabase } from 'expo-sqlite'; import { newId } from './notes-repo'; +/** + * Reads the signed-in account id, or null. + * + * Passed in rather than read from a table because the session is owned by + * Supabase and can change under the app at any time -- a token refresh, a sign + * out on another device. Holding a copy in SQLite would mean two answers to + * "who am I" and a way for them to disagree. + */ +export type CurrentUserId = () => string | null; + /** * All social persistence goes through here. * @@ -19,9 +29,10 @@ import { newId } from './notes-repo'; */ export interface SocialRepo { // --- identity --- - /** The profile this device posts as, or null before a handle is claimed. */ + /** The signed-in account's profile, or null when signed out or unclaimed. */ me(): Promise; - claim(handle: string, displayName: string | null): Promise; + /** Claims a handle for the signed-in account. Null when signed out. */ + claim(handle: string, displayName: string | null): Promise; updateMe(patch: ProfilePatch): Promise; profile(id: string): Promise; profileByHandle(handle: string): Promise; @@ -158,29 +169,31 @@ const ITEM_SELECT = ` join profiles p on p.id = f.author_id `; -export function createSocialRepo(db: SQLiteDatabase): SocialRepo { - /** The reader's id, or a sentinel that matches nothing before a handle exists. */ - async function myId(): Promise { - const row = await db.getFirstAsync<{ profile_id: string }>( - 'select profile_id from social_identity where id = 1', - ); - return row?.profile_id ?? ''; +export function createSocialRepo(db: SQLiteDatabase, currentUserId: CurrentUserId): SocialRepo { + /** The reader's id, or a sentinel that matches no row when signed out. */ + function myId(): string { + return currentUserId() ?? ''; } return { async me() { - const row = await db.getFirstAsync( - `select p.* from profiles p - join social_identity s on s.profile_id = p.id - where s.id = 1`, - ); + const id = myId(); + if (id === '') return null; + + const row = await db.getFirstAsync('select * from profiles where id = ?', id); return row ? toProfile(row) : null; }, async claim(handle, displayName) { + // The profile *is* the account, keyed by the same id. There is no path + // to a second identity on one account, and no local id to reconcile + // against the server later. + const id = myId(); + if (id === '') return null; + const now = new Date().toISOString(); const profile: Profile = { - id: newId(), + id, handle, displayName, bio: null, @@ -188,25 +201,17 @@ export function createSocialRepo(db: SQLiteDatabase): SocialRepo { createdAt: now, }; - await db.withTransactionAsync(async () => { - await db.runAsync( - `insert into profiles - (id, handle, display_name, bio, avatar_url, created_at, updated_at) - values (?, ?, ?, null, null, ?, ?)`, - [profile.id, profile.handle, profile.displayName, now, now], - ); - // The check on `id = 1` makes a second identity a constraint error - // rather than a silently split account. - await db.runAsync('insert into social_identity (id, profile_id) values (1, ?)', [ - profile.id, - ]); - }); + await db.runAsync( + `insert into profiles (id, handle, display_name, bio, avatar_url, created_at, updated_at) + values (?, ?, ?, null, null, ?, ?)`, + [profile.id, profile.handle, profile.displayName, now, now], + ); return profile; }, async updateMe(patch) { - const id = await myId(); + const id = myId(); if (id === '') return null; const columns: Record = { @@ -274,14 +279,14 @@ export function createSocialRepo(db: SQLiteDatabase): SocialRepo { async items() { const rows = await db.getAllAsync( `${ITEM_SELECT} order by f.active_at desc`, - await myId(), + myId(), ); return rows.map(toItem); }, async item(id) { const row = await db.getFirstAsync(`${ITEM_SELECT} where f.id = ?`, [ - await myId(), + myId(), id, ]); return row ? toItem(row) : null; @@ -290,7 +295,7 @@ export function createSocialRepo(db: SQLiteDatabase): SocialRepo { async byAuthor(authorId) { const rows = await db.getAllAsync( `${ITEM_SELECT} where f.author_id = ? order by f.active_at desc`, - [await myId(), authorId], + [myId(), authorId], ); return rows.map(toItem); }, @@ -349,13 +354,13 @@ export function createSocialRepo(db: SQLiteDatabase): SocialRepo { async following() { const rows = await db.getAllAsync<{ followee_id: string }>( 'select followee_id from follows where follower_id = ?', - await myId(), + myId(), ); return new Set(rows.map((row) => row.followee_id)); }, async setFollowing(authorId, following) { - const id = await myId(); + const id = myId(); if (id === '' || id === authorId) return; if (following) { @@ -385,7 +390,7 @@ export function createSocialRepo(db: SQLiteDatabase): SocialRepo { }, async setLiked(itemId, liked) { - const id = await myId(); + const id = myId(); if (id === '') return; if (liked) { @@ -400,7 +405,7 @@ export function createSocialRepo(db: SQLiteDatabase): SocialRepo { }, async blocked() { - const id = await myId(); + const id = myId(); const rows = await db.getAllAsync<{ blocker_id: string; blocked_id: string }>( 'select blocker_id, blocked_id from blocks where blocker_id = ? or blocked_id = ?', [id, id], @@ -416,7 +421,7 @@ export function createSocialRepo(db: SQLiteDatabase): SocialRepo { }, async setBlocked(authorId, blocked) { - const id = await myId(); + const id = myId(); if (id === '' || id === authorId) return; if (blocked) { @@ -443,7 +448,7 @@ export function createSocialRepo(db: SQLiteDatabase): SocialRepo { }, async report(itemId, reason) { - const id = await myId(); + const id = myId(); await db.runAsync( `insert into reports (id, reporter_id, note_id, reason, status, created_at) values (?, ?, ?, ?, 'open', ?)`, diff --git a/apps/mobile/src/lib/auth-providers.ts b/apps/mobile/src/lib/auth-providers.ts new file mode 100644 index 0000000..5e2e1cd --- /dev/null +++ b/apps/mobile/src/lib/auth-providers.ts @@ -0,0 +1,168 @@ +import { GoogleSignin } from '@react-native-google-signin/google-signin'; +import * as AppleAuthentication from 'expo-apple-authentication'; +import { Platform } from 'react-native'; + +import { isAuthConfigured, supabase } from '@/lib/supabase'; + +/** + * The three ways in, behind one shape. + * + * All three end at `signInWithIdToken` or `verifyOtp` and produce the same + * `auth.users` row, so nothing downstream -- profiles, publishing, the feed -- + * ever asks how somebody signed in. That is the point of doing this in one + * file: the rest of the app sees an account, not a provider. + * + * Why these three, in this order: + * + * - **Google** is the one people will actually use on Android. + * - **Email code** is the escape hatch for anyone who does not want Google + * holding the relationship. Six digits, not a password: there is nothing to + * breach and no reset flow to build. Not a magic link either -- a link means + * mail app to browser to deep link back into the app, and that handoff breaks + * often enough to lose people. + * - **Apple** is required by App Store guideline 4.8 the moment Google ships, + * because an email code cannot satisfy 4.8's "keep the email private" test. + * It is wired now so the iOS build needs no new code, and hidden on Android + * where nothing can have an Apple identity attached yet. + */ + +export type AuthResult = + | { ok: true } + | { ok: false; cancelled: true } + | { ok: false; cancelled: false; message: string }; + +const CANCELLED: AuthResult = { ok: false, cancelled: true }; + +function failed(message: string): AuthResult { + return { ok: false, cancelled: false, message }; +} + +const NOT_CONFIGURED = failed('Sign-in is not set up in this build.'); + +/** + * The OAuth client that identifies this app to Google. + * + * It is the *web* client id even on Android, and that trips everyone up once: + * the Android client is matched by package name and signing fingerprint in the + * Google console and is never named here, while the web client is the audience + * Supabase validates the id token against. + */ +const GOOGLE_WEB_CLIENT_ID = process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID ?? ''; + +export const canUseGoogle = GOOGLE_WEB_CLIENT_ID.length > 0 && isAuthConfigured; + +/** + * Apple is offered on iOS only. + * + * The flow works on Android through a web redirect, but until an iOS build + * ships nobody can have an Apple identity to come back to, so the button would + * be an empty room. When iOS lands this becomes a real availability check. + */ +export const canUseApple = Platform.OS === 'ios' && isAuthConfigured; + +let googleConfigured = false; + +function configureGoogle(): void { + if (googleConfigured) return; + GoogleSignin.configure({ webClientId: GOOGLE_WEB_CLIENT_ID }); + googleConfigured = true; +} + +export async function signInWithGoogle(): Promise { + if (supabase === null || !canUseGoogle) return NOT_CONFIGURED; + + try { + configureGoogle(); + await GoogleSignin.hasPlayServices({ showPlayServicesUpdateDialog: true }); + + const response = await GoogleSignin.signIn(); + if (response.type !== 'success') return CANCELLED; + + const idToken = response.data.idToken; + if (idToken === null) { + // Reachable when the console's web client id does not match the one + // configured here -- Google returns a user but no token to verify. + return failed('Google did not return a token. Check the web client id.'); + } + + const { error } = await supabase.auth.signInWithIdToken({ + provider: 'google', + token: idToken, + }); + return error === null ? { ok: true } : failed(error.message); + } catch (error) { + return failed(messageOf(error)); + } +} + +export async function signInWithApple(): Promise { + if (supabase === null || !canUseApple) return NOT_CONFIGURED; + + try { + const credential = await AppleAuthentication.signInAsync({ + requestedScopes: [ + AppleAuthentication.AppleAuthenticationScope.FULL_NAME, + AppleAuthentication.AppleAuthenticationScope.EMAIL, + ], + }); + + if (credential.identityToken === null) return failed('Apple did not return a token.'); + + const { error } = await supabase.auth.signInWithIdToken({ + provider: 'apple', + token: credential.identityToken, + }); + return error === null ? { ok: true } : failed(error.message); + } catch (error) { + // The user tapping Cancel arrives here as a thrown error, not a value. + if (isAppleCancellation(error)) return CANCELLED; + return failed(messageOf(error)); + } +} + +/** Sends the six-digit code. Creates the account if the address is new. */ +export async function sendEmailCode(email: string): Promise { + if (supabase === null) return NOT_CONFIGURED; + + const { error } = await supabase.auth.signInWithOtp({ + email: email.trim(), + options: { shouldCreateUser: true }, + }); + return error === null ? { ok: true } : failed(error.message); +} + +export async function verifyEmailCode(email: string, code: string): Promise { + if (supabase === null) return NOT_CONFIGURED; + + const { error } = await supabase.auth.verifyOtp({ + email: email.trim(), + token: code.trim(), + type: 'email', + }); + return error === null ? { ok: true } : failed(error.message); +} + +export async function signOut(): Promise { + if (supabase === null) return; + await supabase.auth.signOut(); + // Google keeps its own session. Leaving it signed in means the next tap + // silently returns the same account with no chooser, which is not what + // somebody who just signed out expects. + if (googleConfigured) await GoogleSignin.signOut().catch(() => null); +} + +function isAppleCancellation(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code: unknown }).code === 'ERR_REQUEST_CANCELED' + ); +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : 'Something went wrong.'; +} + +/** A plain check the sign-in screen uses to decide what to draw. */ +export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; diff --git a/apps/mobile/src/lib/supabase.ts b/apps/mobile/src/lib/supabase.ts new file mode 100644 index 0000000..0282822 --- /dev/null +++ b/apps/mobile/src/lib/supabase.ts @@ -0,0 +1,58 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; +import { AppState } from 'react-native'; + +/** + * The Supabase client, or null when the app was built without credentials. + * + * Null is a supported state, not an error. Rollout 1 is a complete notes app + * with no account and no network, so a build with no Supabase project must + * still launch, still write notes, and still read the feed -- it simply cannot + * sign anybody in. Every caller checks `isAuthConfigured` rather than assuming + * a client exists. + */ + +const url = process.env.EXPO_PUBLIC_SUPABASE_URL ?? ''; +const anonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? ''; + +export const isAuthConfigured = url.length > 0 && anonKey.length > 0; + +export const supabase: SupabaseClient | null = isAuthConfigured + ? createClient(url, anonKey, { + auth: { + // The session lives in AsyncStorage rather than SecureStore: a Supabase + // session is a few kilobytes of JSON and Android's keystore-backed + // store is documented as unreliable above 2KB per value. The refresh + // token is the thing worth protecting, and the hardening move is a + // short token lifetime on the server, not a store that truncates. + storage: AsyncStorage, + autoRefreshToken: true, + persistSession: true, + // No URL to parse: OAuth is completed by a native SDK handing us an + // id token, never by a redirect the app has to read. + detectSessionInUrl: false, + }, + }) + : null; + +/** + * Refreshes the token only while the app is in front of the user. + * + * Without this the timer keeps firing in the background, which on Android + * means refresh calls from a process that is about to be frozen -- they fail, + * and the user comes back signed out. + */ +export function watchAppStateForRefresh(): () => void { + if (supabase === null) return () => {}; + + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + void supabase.auth.startAutoRefresh(); + } else { + void supabase.auth.stopAutoRefresh(); + } + }); + + if (AppState.currentState === 'active') void supabase.auth.startAutoRefresh(); + return () => subscription.remove(); +} diff --git a/apps/mobile/src/store/auth-store.tsx b/apps/mobile/src/store/auth-store.tsx new file mode 100644 index 0000000..fd83d57 --- /dev/null +++ b/apps/mobile/src/store/auth-store.tsx @@ -0,0 +1,90 @@ +import type { Session } from '@supabase/supabase-js'; +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; + +import { signOut as signOutOfProviders } from '@/lib/auth-providers'; +import { isAuthConfigured, supabase, watchAppStateForRefresh } from '@/lib/supabase'; + +/** + * Who is signed in, if anyone. + * + * "Nobody" is the normal state, not a loading gap. The notes half of the app + * never reads this store: writing, editing, notebooks, the review loop and + * Recently deleted all work with no account, forever. Only publishing asks. + */ + +interface AuthContextValue { + session: Session | null; + /** The account id, which is also the profile's primary key once one exists. */ + userId: string | null; + /** False until the stored session has been read back from disk. */ + ready: boolean; + /** False in a build with no Supabase credentials compiled in. */ + configured: boolean; + signOut: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(null); + const [ready, setReady] = useState(!isAuthConfigured); + + useEffect(() => { + if (supabase === null) return; + + let active = true; + + // The stored session comes back from AsyncStorage asynchronously, so there + // is a moment where a signed-in user looks signed out. `ready` covers it; + // rendering a sign-in button during that moment would be a visible flicker + // on every cold start. + void supabase.auth.getSession().then(({ data }) => { + if (!active) return; + setSession(data.session); + setReady(true); + }); + + const { data } = supabase.auth.onAuthStateChange((_event, next) => { + if (active) setSession(next); + }); + + const stopRefreshing = watchAppStateForRefresh(); + + return () => { + active = false; + data.subscription.unsubscribe(); + stopRefreshing(); + }; + }, []); + + const value = useMemo( + () => ({ + session, + userId: session?.user.id ?? null, + ready, + configured: isAuthConfigured, + async signOut() { + await signOutOfProviders(); + setSession(null); + }, + }), + [session, ready], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (context === null) { + throw new Error('useAuth must be used inside an '); + } + return context; +} diff --git a/apps/mobile/src/store/social-store.tsx b/apps/mobile/src/store/social-store.tsx index eed90b6..81e8a5a 100644 --- a/apps/mobile/src/store/social-store.tsx +++ b/apps/mobile/src/store/social-store.tsx @@ -22,6 +22,7 @@ import { import { createSocialRepo, type ProfilePatch, type SocialRepo } from '@/db/social-repo'; import { seedSocial } from '@/lib/social-seed'; +import { useAuth } from '@/store/auth-store'; import { useNotes } from '@/store/notes-store'; /** @@ -38,8 +39,13 @@ import { useNotes } from '@/store/notes-store'; */ interface SocialContextValue { - /** Null until a handle is claimed. Everything that writes requires it. */ + /** Null until signed in *and* a handle is claimed. Everything that writes requires it. */ me: Profile | null; + /** + * What the reader still has to do before they can publish, or null if + * nothing. Reading the feed is never gated -- this only ever guards writes. + */ + needed: 'sign-in' | 'handle' | null; loading: boolean; /** Every mirrored item. `composeFeed` turns this into what a reader sees. */ items: FeedItem[]; @@ -73,9 +79,15 @@ const SocialContext = createContext(null); export function SocialProvider({ children }: { children: ReactNode }) { const db = useSQLiteContext(); - const repo = useMemo(() => createSocialRepo(db), [db]); + const { userId, ready: authReady } = useAuth(); const { notes, repo: notesRepo, refresh: refreshNotes } = useNotes(); + // Rebuilt when the session changes, which is what makes signing in or out + // reload the screens: `me`, `following` and "liked by me" all resolve + // through the account id this closure captures. The load effect downstream + // re-runs with it, and re-seeding is a no-op after the first time. + const repo = useMemo(() => createSocialRepo(db, () => userId), [db, userId]); + const [me, setMe] = useState(null); const [items, setItems] = useState([]); const [following, setFollowingIds] = useState>(new Set()); @@ -156,6 +168,10 @@ export function SocialProvider({ children }: { children: ReactNode }) { return { me, + // Reading needs nothing; publishing needs an account and then a handle. + // Held apart because they are two different screens and the second one + // is where the product's one identity decision gets made. + needed: !authReady ? null : userId === null ? 'sign-in' : me === null ? 'handle' : null, loading, items, following, @@ -170,7 +186,9 @@ export function SocialProvider({ children }: { children: ReactNode }) { if (await repo.handleTaken(normalized)) return 'Somebody has that one.'; const trimmed = displayName.trim(); - await repo.claim(normalized, trimmed.length > 0 ? trimmed : null); + const claimed = await repo.claim(normalized, trimmed.length > 0 ? trimmed : null); + if (claimed === null) return 'Sign in first.'; + await refresh(); return null; }, @@ -181,6 +199,7 @@ export function SocialProvider({ children }: { children: ReactNode }) { }, shareError(note) { + if (userId === null) return 'Sign in to publish.'; if (me === null) return 'Claim a handle first.'; return publishError(note); }, @@ -238,7 +257,20 @@ export function SocialProvider({ children }: { children: ReactNode }) { await repo.recordView(itemId); }, }; - }, [me, loading, items, following, blocked, repo, refresh, notes, notesRepo, refreshNotes]); + }, [ + me, + userId, + authReady, + loading, + items, + following, + blocked, + repo, + refresh, + notes, + notesRepo, + refreshNotes, + ]); return {children}; } diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..e62ca35 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,121 @@ +# Accounts + +Three ways in, one account. Google, an emailed six-digit code, and Sign in with Apple. + +**Notes never require any of this.** Writing, editing, notebooks, the review loop, locking and +Recently deleted all work with no account and no network, forever. The app asks for an account at +exactly one moment: when you try to put something where other people can read it. A build with no +credentials compiled in still launches, still takes notes, and still reads the feed — it just +cannot sign anybody in. + +## Why these three + +| | Why it exists | +|---|---| +| **Google** | What people on Android will actually use | +| **Email code** | The escape hatch for anyone who does not want Google holding the relationship | +| **Apple** | Required by App Store guideline 4.8 once Google ships. Wired now, shown on iOS only | + +Guideline 4.8 says that if you use a third-party login to set up a primary account, you must also +offer another service that limits collection to name and email, **lets the user keep their email +address private**, and does not harvest in-app activity for ads. An emailed code fails the middle +test — the whole mechanism is that we receive the address — so it does not discharge 4.8 on its +own. Sign in with Apple does. This supersedes decision 4 in [plan.md](plan.md), which was correct +while magic-link was the only option. + +Apple is hidden on Android by `canUseApple` in `src/lib/auth-providers.ts`. It works there through +a web redirect, but until an iOS build ships nobody has an Apple identity to come back to. When +iOS lands, that constant is the only thing to change. + +**No passwords, anywhere.** A six-digit code means nothing to breach, no reset flow to build, and +no credential to store. A code rather than a magic link because a link is mail app → browser → +deep link back into the app, and that handoff fails often enough to lose people. + +## Setup + +Four things, in this order. Nothing below is inferable from the repo — it all lives in consoles. + +### 1. Supabase + +Create a project, then apply the schema: + +```bash +supabase link --project-ref +supabase db push # applies supabase/migrations/, including the social tables +``` + +Take the project URL and the **anon** key from Project Settings → API. + +### 2. Google + +In the [Google Cloud console](https://console.cloud.google.com/apis/credentials), create OAuth +client IDs under one project: + +- **Web application** — this is the one the app and Supabase both need. Copy its client ID and + secret into Supabase → Authentication → Providers → Google. +- **Android** — package name `com.zacktiger.dailynote`, plus the SHA-1 of the signing certificate. + You need two of these eventually: your EAS debug keystore and your Play upload key. Get the + fingerprint with `eas credentials`. This client is never named in the code — Google matches it + by package name and fingerprint. + +> The single most common failure is putting the Android client ID in `EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID`. +> It must be the **web** client ID: that is the audience Supabase validates the token against. The +> symptom is a successful Google sheet followed by "Google did not return a token". + +### 3. Apple — only when you ship iOS + +Enable Sign in with Apple on the App ID, create a Services ID and a signing key, and put the key +into Supabase → Authentication → Providers → Apple. Skip this entirely for an Android-only launch; +the code path is already there. + +### 4. Environment + +Create `apps/mobile/.env.local` (git-ignored): + +```bash +EXPO_PUBLIC_SUPABASE_URL=https://.supabase.co +EXPO_PUBLIC_SUPABASE_ANON_KEY= +EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID=.apps.googleusercontent.com +``` + +`EXPO_PUBLIC_` variables are compiled into the bundle and are **not secret** — the anon key is +designed for this and is protected by row-level security, and a public OAuth client ID is public by +definition. Never add a service-role key here. + +Then rebuild. Google Sign-In is native code, so Expo Go cannot run it: + +```bash +npx expo prebuild --clean +npx expo run:android # or: eas build --profile development --platform android +``` + +## Turn on identity linking before launch + +Supabase → Authentication → Settings → **automatically link identities with matching, verified +emails**. + +Without it, somebody who signs up with Google as `you@gmail.com` and later types that same address +into the email form gets a *second* `auth.users` row — a different account, with none of their +published work. It looks to them like their profile vanished. This is cheap to enable now and +expensive to reconcile later. + +## What the app does with an account + +Almost nothing, and deliberately. + +- `profiles.id` **is** `auth.users.id`. The profile is the account wearing a face; there is no + second identity to reconcile and no local id to remap when sync lands. +- **The handle is never derived from the provider.** Google hands over a real name and an email; + neither is used to prefill anything. Decision 7 in + [plan-rollout-2.md](plan-rollout-2.md) allows pseudonymous accounts on purpose, and autofilling + a real name would quietly convert this into a real-name network. +- Signing out leaves every note on the device. Notes were never the account's. + +## Still to do + +- **Sync.** Signing in gives an account but notes are still local-only; Rollout 1 Phase 4 is what + connects them, and it is what makes `auth.users.id` on a note row mean anything. +- **Account deletion.** Guideline 5.1.1(v) requires in-app deletion, and + [plan-rollout-2 section 5](plan-rollout-2.md) requires that it really remove published content. + Neither exists yet. +- **Rate limiting** on code sends, beyond whatever Supabase does by default. diff --git a/docs/plan.md b/docs/plan.md index 3a0d69f..6b629c6 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -302,7 +302,9 @@ This is the differentiator. Do not compress it. ### Phase 4 — Accounts and sync · ~2 weeks Deliberately after the app is good. Sync will eat weeks and it is not the product. -- Supabase Auth, email + magic link +- ~~Supabase Auth, email + magic link~~ — **done, and it is three providers, not one**: Google, + a six-digit email code (not a magic link — the mail-to-browser-to-app handoff loses people), and + Sign in with Apple. See [auth.md](auth.md). Sync is the part of this phase still outstanding - **Start anonymous.** Users write before signing up; account creation claims existing local notes. Supabase anonymous sign-in handles this. Forcing signup before the first note is the most common way apps like this die @@ -356,7 +358,7 @@ state of all of these. | 3 | License | MIT app + core, AGPL-3.0 DB + server — [ADR 0004](adr/0004-licensing.md) | ✅ | | 5 | Name + bundle ID | **DailyNote**, `com.zacktiger.dailynote`, scheme `dailynote://` | ✅ | | — | Notes-app integration | Dropped, no API exists — [ADR 0003](adr/0003-no-notes-app-integrations.md) | ✅ | -| 4 | Sign in with Apple | **Not required** if magic-link is the only auth. Apple mandates it only when you offer third-party social login. Skipping it is correct for Rollout 1 | Phase 4 | +| 4 | Sign in with Apple | ~~Not required if magic-link is the only auth~~ → **Required.** Google Sign-In shipped, and guideline 4.8's alternative must let users keep their email private, which an emailed code cannot. All three are wired; Apple is iOS-only — [auth.md](auth.md) | ✅ | | 6 | Web | Marketing page only in Rollout 1 — no Expo Web. Rollout 2 adds a separate server-rendered read surface, which is not this | Phase 6 | --- diff --git a/package-lock.json b/package-lock.json index c0e68b7..9670c78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,11 @@ "@expo-google-fonts/newsreader": "^0.4.1", "@expo/ui": "~57.0.8", "@expo/vector-icons": "^15.0.2", + "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-google-signin/google-signin": "^16.1.4", + "@supabase/supabase-js": "^2.112.0", "expo": "~57.0.8", + "expo-apple-authentication": "~57.0.1", "expo-constants": "~57.0.7", "expo-crypto": "~57.0.1", "expo-file-system": "~57.0.1", @@ -46,6 +50,7 @@ "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.2", + "expo-web-browser": "~57.0.2", "nativewind": "^4.2.1", "react": "19.2.3", "react-dom": "19.2.3", @@ -3597,6 +3602,37 @@ } } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, + "node_modules/@react-native-google-signin/google-signin": { + "version": "16.1.4", + "resolved": "https://registry.npmjs.org/@react-native-google-signin/google-signin/-/google-signin-16.1.4.tgz", + "integrity": "sha512-Lt4vjihJ+Nk9I4HjScWHzch3ebtIlP3QIea0XEHVN0PJumwUwiK/J2tU8qs7H9DIqNblC0qlXkR0Ts4ELvq7yA==", + "license": "MIT", + "funding": { + "url": "https://universal-sign-in.com" + }, + "peerDependencies": { + "expo": ">=52.0.40", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, "node_modules/@react-native-masked-view/masked-view": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", @@ -4237,6 +4273,98 @@ "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.112.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.112.0.tgz", + "integrity": "sha512-8qAdObNQHKbSeVBLmf2WLNT7+bCE8zBofpCFiNUqGBAl5qw9VagSvcmPXlh78McAU7iHrGik1oeJxiB2A6B29w==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.112.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.112.0.tgz", + "integrity": "sha512-2DdaEZs0vq86orMIZBO+eM5w5/UxZb1EZyg2JraBKS4W9BzfFO+fOFD6YStmZ7iAOWvxNTGPjPNItsofpGgTCA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.112.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.112.0.tgz", + "integrity": "sha512-4HKCVq32Jlk/wS8Ud8QgaAuQ4u6w1hZfw/gS5IcIN7wYddElMj4kfiCZpHMPfncomMnYzAKBUhwBZPpRcTH2Yw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.112.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.112.0.tgz", + "integrity": "sha512-McFFP+ivFDMTaCEh8JDpG+sPEmv5IjKvrP0uTH3Lbsriai4KbxB8ycY8TqPQnbjhOVjEifQm1q0y2tL1CUw9Zg==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.112.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.112.0.tgz", + "integrity": "sha512-X44Bl045X/e5e2tJqWsY+JmQvgtm04BJuijiWprIWYLn0mDvGnu8hLdzPOPcTVji7wlqbt2ZUHttsv+rGKQYXw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.112.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.112.0.tgz", + "integrity": "sha512-dHVOgog58GOagtrZuPxJYg/R45ZV2U0qqgXffH+lMlt1OS+267Pw4g7bw3iXCGxN85OufiE0nI1baxDXlgEyfQ==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.112.0", + "@supabase/functions-js": "2.112.0", + "@supabase/postgrest-js": "2.112.0", + "@supabase/realtime-js": "2.112.0", + "@supabase/storage-js": "2.112.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -7741,6 +7869,17 @@ } } }, + "node_modules/expo-apple-authentication": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-apple-authentication/-/expo-apple-authentication-57.0.1.tgz", + "integrity": "sha512-pqAIaiTa/ycNl+XqNg5UMVT5TBgl6q6djTtoIcUmDItjVBA1IDHsQEs+leo+8+fG5hhuempZLSqBVVn+RJKKWA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-asset": { "version": "57.0.8", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.8.tgz", @@ -8104,6 +8243,16 @@ } } }, + "node_modules/expo-web-browser": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-57.0.2.tgz", + "integrity": "sha512-3vl5kvd7PB48ub6PpNIJUuPxO8xVa6D8RnIgNba6SXRwqFprOfeEZgwTgtm41kz0AAtvMOztUVNEUkwrHKjqMQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -8864,6 +9013,15 @@ "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -9284,6 +9442,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -10208,6 +10375,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",