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",
]