From 4c105c69104d0960127d89ce6e583d92e1657fe3 Mon Sep 17 00:00:00 2001 From: Kshitij Bachhav Date: Tue, 4 Aug 2026 23:11:07 +0530 Subject: [PATCH] auth: three ways in, asked for only to publish Google, a six-digit email code, and Sign in with Apple, all landing on the same auth.users row so nothing downstream asks how somebody signed in. Notes still need no account. The gate is two steps -- session, then handle -- and `needed` names whichever is outstanding so nobody is sent to a screen they have already satisfied. Reading the feed is never gated; the prompt on the feed header is a prompt, not a wall. Apple is wired and hidden on Android. It becomes required by App Store 4.8 the moment Google ships, because 4.8's alternative must let users keep their email private and an emailed code cannot -- receiving the address is the mechanism. plan.md decision 4 reasoned correctly for magic-link-only auth and is corrected here. A code rather than a magic link: a link means mail app to browser to deep link back into the app, and that handoff fails often enough to lose people. No passwords at all -- nothing to breach, no reset flow. The profile is now the account wearing a face: profiles.id is auth.users.id, so v5 drops the social_identity table that cached a local answer to a question the session already answers. The handle is never prefilled from the provider; plan-rollout-2 decision 7 allows pseudonymous accounts on purpose. A build with no credentials still launches, still takes notes, still reads the feed, and says so on the sign-in screen. docs/auth.md has the console setup, including the two things that reliably go wrong: using the Android client id where the web one belongs, and shipping without automatic identity linking. Not verified: the OAuth round trips need a dev build on a device with real credentials. Typecheck, lint, migrations and the Android bundle pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DRL4dVgg5oebLSTenQv2G --- apps/mobile/.env.example | 15 + apps/mobile/app.json | 8 +- apps/mobile/package.json | 5 + apps/mobile/src/app/(tabs)/social.tsx | 49 ++-- apps/mobile/src/app/_layout.tsx | 83 ++++-- apps/mobile/src/app/handle.tsx | 19 ++ apps/mobile/src/app/note/[id].tsx | 23 +- apps/mobile/src/app/profile/[handle].tsx | 25 +- apps/mobile/src/app/sign-in.tsx | 355 +++++++++++++++++++++++ apps/mobile/src/db/migrations.ts | 22 ++ apps/mobile/src/db/social-repo.ts | 81 +++--- apps/mobile/src/lib/auth-providers.ts | 168 +++++++++++ apps/mobile/src/lib/supabase.ts | 58 ++++ apps/mobile/src/store/auth-store.tsx | 90 ++++++ apps/mobile/src/store/social-store.tsx | 40 ++- docs/auth.md | 121 ++++++++ docs/plan.md | 6 +- package-lock.json | 179 ++++++++++++ 18 files changed, 1243 insertions(+), 104 deletions(-) create mode 100644 apps/mobile/.env.example create mode 100644 apps/mobile/src/app/sign-in.tsx create mode 100644 apps/mobile/src/lib/auth-providers.ts create mode 100644 apps/mobile/src/lib/supabase.ts create mode 100644 apps/mobile/src/store/auth-store.tsx create mode 100644 docs/auth.md 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",