diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index e960fb112..68c70b66e 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -31,6 +31,9 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`). - One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm, Factoids, Crossfire and Sparring forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. - Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts` and `sparring.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats and judged Sparring rounds) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` and the Flowchart until each copy shown is read from the server again or the screen showing it closes, moves on or leaves view (a screen passes `useCopyHolder().mark()` with its reads). A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy; a read made of several queries runs them through `readAll` (or `pickFailure`), so one query's refusal wins over another's missing answer. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead`, takes an optional `CopyMark`, and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists; a delete uses `deletedListScopes`, which drops every debate's when no row came back), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value. +- Push: `push.ts` asks for permission and files the Expo push token under the signed-in user with the `register_push_token` RPC; the You tab's Notifications switch turns it on and off, the root layout registers again on launch and when the language changes, and sign-out releases the registration with the account's own access token, after any registration still in flight. Every registration carries the ID of the local record, and `release_push_token` withdraws that ID and deletes only the rows it wrote, so a registration the app gave up on is refused if it reaches the database after the release. Once a release is sent for an ID, the record is marked released and the next registration gets a new ID, so a late release cannot delete it. The local record is cleared only once a release succeeds; otherwise the next change on the phone, from any account, releases it first. The server side is in [supabase/README.md](../../supabase/README.md#push-notifications). `push.web.ts` registers nothing and only flips the switch for the screenshot script. + - No build can get a token until `app.config.ts` has `extra.eas.projectId`, from `eas init` on build day (see [docs/development.md](../../docs/development.md#build-day)). Until then the switch shows as unavailable, on Expo Go too. Expo Go on Android has no remote push since SDK 53, and Expo recommends a development build for remote push on iOS as well. + - Prebuild applies the `expo-notifications` config plugin automatically once the package is installed, even though `app.config.ts` does not list it, and the plugin sets the `aps-environment` entitlement. `DEBATELAB_BUILD=dev` removes it again (`withoutPushEntitlement`), because the borrowed Signulous profile has no push, so a development build cannot get a token even with a project ID: turning the switch on there fails. Real push needs a provisioning profile with the Push Notifications capability and an APNs key uploaded to EAS, both from Adam's own Apple Developer account; only then can `withoutPushEntitlement` go. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 26762557a..a9f05c738 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -30,6 +30,7 @@ "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", "expo-network": "~57.0.2", + "expo-notifications": "~57.0.22", "expo-print": "~57.0.2", "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 39d146cce..67b1f1680 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -8,6 +8,9 @@ * PORT= npx tsx apps/mobile/scripts/screenshots.ts * * ONLY=iphone-light-en limits a run to the combinations whose name starts with it. + * SCREENS=you,timer limits the signed-in screens to those groups: debate, + * library, prepare, practice, you, empty, ask, results, brainstorm, factoids, + * timer, crossfire, sparring, flowchart, one-off. Sign-in screens are always shot. * ONLY=offline runs only the offline reading check (see shootOfflineScreens). * * Signed-in screens need a staging session. With STAGING_DEMO_CODE set, the @@ -75,6 +78,8 @@ const OUT = join(__dirname, '../../../.verify/mobile-shots'); const PORT = Number(process.env.PORT); const DEMO_CODE = process.env.STAGING_DEMO_CODE?.trim(); const ONLY = process.env.ONLY?.trim() ?? ''; +const SCREENS = process.env.SCREENS?.split(',').map((name) => name.trim()).filter(Boolean); +const want = (group: string) => !SCREENS?.length || SCREENS.includes(group); const ORIGIN = `http://127.0.0.1:${PORT}`; const STAGING_HOST = new URL(SUPABASE_PROJECTS.staging.url).host; const SESSION_KEY = `sb-${STAGING_HOST.split('.')[0]}-auth-token`; @@ -86,6 +91,7 @@ const DEVICES = { const SCHEMES = ['light', 'dark'] as const; const LOCALES = { en: 'en-GB', hu: 'hu-HU' } as const; const SIGNED_IN_ROUTES = { prepare: '/prepare', practice: '/practice', you: '/you' }; +const NOTIFICATIONS = { en: 'Notifications', hu: 'Értesítések' } as const; const REST = `https://${STAGING_HOST}/rest/v1`; const TYPES: Record = { @@ -1079,7 +1085,13 @@ async function signIn(browser: Browser, problems: string[]): Promise { + const alert = await page.getByRole('alert').first().textContent({ timeout: 1_000 }).catch(() => null); + // The field shows the code in plain text; empty it so the screenshot never holds it. + await page.getByRole('textbox').fill(''); + await shoot(page, 'sign-in-failed'); + throw new Error(`Demo-code sign-in did not finish${alert ? `: ${alert}` : ''}`, { cause: error }); + }); if (authHosts.length === 0 || authHosts.some((host) => host !== STAGING_HOST)) { throw new Error(`Sign-in reached ${[...new Set(authHosts)].join(', ') || 'no backend'}, expected ${STAGING_HOST}`); } @@ -1372,51 +1384,82 @@ async function main() { const tabPage = await signedIn.newPage(); watchErrors(tabPage, problems, `${prefix} signed in`); await pinSettings(tabPage, language); - await shootDebateScreens(tabPage, prefix).catch(async (error: unknown) => { - await shoot(tabPage, `${prefix}-failed`); - throw error; - }); - shots += 6; - await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => { - await shoot(tabPage, `${prefix}-failed`); - throw error; - }); - shots += 5; + if (want('debate')) { + await shootDebateScreens(tabPage, prefix).catch(async (error: unknown) => { + await shoot(tabPage, `${prefix}-failed`); + throw error; + }); + shots += 6; + } + if (want('library')) { + await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => { + await shoot(tabPage, `${prefix}-failed`); + throw error; + }); + shots += 5; + } for (const [name, route] of Object.entries(SIGNED_IN_ROUTES)) { + if (!want(name)) continue; await tabPage.goto(`${ORIGIN}${route}`); await tabPage.getByRole('tablist').first().waitFor(); if (name === 'you') await tabPage.getByTestId('subscription-tier').waitFor({ timeout: 30_000 }); await settle(tabPage); await shoot(tabPage, `${prefix}-${name}`); shots += 1; + if (name === 'you') { + // The web preview turns notifications on without registering anything (push.web.ts). + const notifications = tabPage.getByRole('switch', { name: NOTIFICATIONS[language as keyof typeof NOTIFICATIONS] }); + await notifications.scrollIntoViewIfNeeded(); + await notifications.click(); + await tabPage.waitForFunction(() => document.querySelector('[role="switch"]:checked') !== null); + await shoot(tabPage, `${prefix}-you-notifications-on`); + shots += 1; + } + } + if (want('empty')) { + const emptyPage = await signedIn.newPage(); + watchErrors(emptyPage, problems, `${prefix} empty`); + await pinSettings(emptyPage, language); + await shootEmptyDebate(emptyPage, prefix); + shots += 2; + } + if (want('ask')) { + await shootAskScreens(signedIn, prefix, language, problems); + shots += 3; + } + if (want('results')) { + await shootResultScreens(signedIn, prefix, language, problems); + shots += 9; } - const emptyPage = await signedIn.newPage(); - watchErrors(emptyPage, problems, `${prefix} empty`); - await pinSettings(emptyPage, language); - await shootEmptyDebate(emptyPage, prefix); - shots += 2; - await shootAskScreens(signedIn, prefix, language, problems); - shots += 3; - await shootResultScreens(signedIn, prefix, language, problems); - shots += 9; for (const tool of ['brainstorm', 'factoids'] as const) { + if (!want(tool)) continue; await shootTool(signedIn, prefix, language, problems, tool); shots += 6; } - await shootTimerScreens(signedIn, prefix, language, problems); - shots += 3; - await shootCrossfireScreens(signedIn, prefix, language, problems); - shots += 3; - await shootSparringScreens(signedIn, prefix, language, problems); - shots += 6; - await shootFlowchart(signedIn, prefix, language, problems); - shots += 10; - const oneOffPage = await signedIn.newPage(); - watchErrors(oneOffPage, problems, `${prefix} one-off tools`); - await pinSettings(oneOffPage, language); - await shootOneOffTools(oneOffPage, prefix); - await oneOffPage.close(); - shots += 2; + if (want('timer')) { + await shootTimerScreens(signedIn, prefix, language, problems); + shots += 3; + } + if (want('crossfire')) { + await shootCrossfireScreens(signedIn, prefix, language, problems); + shots += 3; + } + if (want('sparring')) { + await shootSparringScreens(signedIn, prefix, language, problems); + shots += 6; + } + if (want('flowchart')) { + await shootFlowchart(signedIn, prefix, language, problems); + shots += 10; + } + if (want('one-off')) { + const oneOffPage = await signedIn.newPage(); + watchErrors(oneOffPage, problems, `${prefix} one-off tools`); + await pinSettings(oneOffPage, language); + await shootOneOffTools(oneOffPage, prefix); + await oneOffPage.close(); + shots += 2; + } await signedIn.close(); } } diff --git a/apps/mobile/src/app/(tabs)/(you)/you.tsx b/apps/mobile/src/app/(tabs)/(you)/you.tsx index cd8d15ce9..892e5e1e5 100644 --- a/apps/mobile/src/app/(tabs)/(you)/you.tsx +++ b/apps/mobile/src/app/(tabs)/(you)/you.tsx @@ -1,11 +1,12 @@ import { APP_LANGUAGES, type AppLanguage } from '@debatelab/core/languages'; import { useFocusEffect, useRouter } from 'expo-router'; -import { useCallback, useState, type ReactNode } from 'react'; +import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { ActivityIndicator, AppState, Linking, Pressable, StyleSheet, View } from 'react-native'; import { TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; +import { SwitchRow } from '@/components/form-rows'; import { Icon } from '@/components/icon'; import { ScreenTitle } from '@/components/screen-title'; import { Screen } from '@/components/screen'; @@ -13,6 +14,8 @@ import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { formatDate } from '@/lib/dates'; +import { disablePush, enablePush, readPushState, type PushState } from '@/lib/push'; +import { createPushSwitch, type PushSwitchView } from '@/lib/push-switch'; import { useSettings, type ThemePreference } from '@/lib/settings'; import { fetchSubscription, type Subscription, type SubscriptionTier } from '@/lib/subscription'; import { supabaseEnv } from '@/lib/supabase-config'; @@ -90,6 +93,8 @@ export default function YouScreen() { ) : null} + + {t('settings.account')} @@ -144,6 +149,63 @@ function Setting({ label, children }: { label: string; children: ReactNode }) { ); } +const PUSH_DESCRIPTIONS: Record = { + on: 'mobile.you.notifications.description', + off: 'mobile.you.notifications.description', + blocked: 'mobile.you.notifications.blocked', + unavailable: 'mobile.you.notifications.unavailable', +}; + +/** Push notifications for this account on this phone (lib/push.ts). */ +function NotificationsCard({ userId }: { userId: string | undefined }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { language } = useSettings(); + const [{ state, busy, failed }, setView] = useState({ state: null, busy: false, failed: false }); + const [pushSwitch] = useState(() => createPushSwitch((patch) => setView((view) => ({ ...view, ...patch })))); + + const load = useCallback(() => { + if (userId) pushSwitch.load(() => readPushState(userId)); + }, [userId, pushSwitch]); + + // The switch may have changed in iOS Settings while DebateLab was in the background. + useFocusEffect(load); + useEffect(() => { + const subscription = AppState.addEventListener('change', (next) => { + if (next === 'active') load(); + }); + return () => subscription.remove(); + }, [load]); + + const change = (on: boolean) => { + if (!userId) return; + pushSwitch.change(() => (on ? enablePush(userId, language) : disablePush(userId).then((): PushState => 'off'))); + }; + + return ( + + + {state === 'blocked' ? ( + + void Linking.openSettings()} /> + + ) : null} + {failed ? ( + + {t('mobile.you.notifications.failed')} + + ) : null} + + ); +} + function SubscriptionCard({ userId }: { userId: string | undefined }) { const { t } = useTranslation(); const { colors } = useTheme(); @@ -245,4 +307,5 @@ const styles = StyleSheet.create({ rowTitle: { fontSize: 16 }, email: { fontSize: 17 }, pressed: { opacity: 0.6 }, + inset: { paddingHorizontal: 16, paddingBottom: 12 }, }); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 65b0d75bc..2cc70e34f 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -12,6 +12,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { AuthProvider, useAuth } from '@/lib/auth'; import { DebateProvider } from '@/lib/debate-context'; import { watchNetwork } from '@/lib/network-status'; +import { refreshPush } from '@/lib/push'; import { SettingsProvider, useSettings } from '@/lib/settings'; import { interFonts } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; @@ -33,7 +34,7 @@ export default function RootLayout() { function Root() { const [fontsLoaded, fontError] = useFonts(interFonts); const { session, loading, settled } = useAuth(); - const { ready: settingsReady } = useSettings(); + const { ready: settingsReady, language } = useSettings(); const { scheme, colors } = useTheme(); // Settings come from the device here, so the app opens in the account's language and theme. const ready = (fontsLoaded || fontError !== null) && !loading && settingsReady; @@ -45,6 +46,15 @@ function Root() { // Offline, reads answer from saved copies and the screens say so. useEffect(() => watchNetwork(), []); + // A phone that opted in registers again on launch and when the language changes (see refreshPush). + const userId = session?.user.id; + useEffect(() => { + if (!userId || !settingsReady) return; + refreshPush(userId, language).catch((cause: unknown) => { + console.warn('Could not refresh the push token:', cause instanceof Error ? cause.message : cause); + }); + }, [userId, language, settingsReady]); + if (!ready) return null; // Native form sheets with a grabber on iOS. The web preview shows them as full pages. diff --git a/apps/mobile/src/lib/auth.tsx b/apps/mobile/src/lib/auth.tsx index 120607da9..e186eab8a 100644 --- a/apps/mobile/src/lib/auth.tsx +++ b/apps/mobile/src/lib/auth.tsx @@ -3,6 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import { getInstallId, sessionStorage } from './device-storage'; import { setCacheOwner } from './offline-cache'; +import { forgetPushOnSignOut } from './push'; import { setSessionOwner } from './session-memory'; import { supabase } from './supabase'; import { sessionStorageKey } from './supabase-config'; @@ -145,6 +146,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (verificationError) throw verificationError; }, signOut: async () => { + // While the session still works, so the next person on this phone gets none of this account's notifications. + if (session) await forgetPushOnSignOut(session.user.id, session.access_token); // supabase-js loads the session before signing out. Offline with an // expired access token that refresh fails, and it returns the error // without removing the stored session. Signing out must still sign out diff --git a/apps/mobile/src/lib/push-switch.test.ts b/apps/mobile/src/lib/push-switch.test.ts new file mode 100644 index 000000000..8a3edad81 --- /dev/null +++ b/apps/mobile/src/lib/push-switch.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { PushState } from './push'; +import { createPushSwitch, type PushSwitchView } from './push-switch'; + +function deferred() { + let resolve!: (state: PushState) => void; + let reject!: (cause: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function view() { + let current: PushSwitchView = { state: null, busy: false, failed: false }; + const pushSwitch = createPushSwitch((patch) => (current = { ...current, ...patch })); + return { pushSwitch, current: () => current }; +} + +describe('createPushSwitch', () => { + it('frees the switch when the app comes back to the foreground during a change', async () => { + const { pushSwitch, current } = view(); + const write = deferred(); + pushSwitch.change(() => write.promise); + expect(current().busy).toBe(true); + + // Foregrounding reloads; the read is queued behind the change, so it answers after it. + const read = deferred(); + pushSwitch.load(() => read.promise); + write.resolve('on'); + await vi.waitFor(() => expect(current().busy).toBe(false)); + read.resolve('on'); + await vi.waitFor(() => expect(current()).toEqual({ state: 'on', busy: false, failed: false })); + }); + + it('still reports a failed change when a read started during it', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { pushSwitch, current } = view(); + const write = deferred(); + pushSwitch.change(() => write.promise); + pushSwitch.load(() => Promise.resolve('off')); + write.reject(new Error('offline')); + await vi.waitFor(() => expect(current()).toEqual({ state: 'off', busy: false, failed: true })); + }); +}); diff --git a/apps/mobile/src/lib/push-switch.ts b/apps/mobile/src/lib/push-switch.ts new file mode 100644 index 000000000..6890e7e6d --- /dev/null +++ b/apps/mobile/src/lib/push-switch.ts @@ -0,0 +1,43 @@ +import type { PushState } from './push'; + +export type PushSwitchView = { state: PushState | null; busy: boolean; failed: boolean }; + +/** + * Orders the notification switch's reads and changes. The newest read or + * change may show its state, so an older answer never overwrites a newer one. + * Only the newest change owns `busy` and `failed`: a read that starts while a + * change runs (the app coming back to the foreground) must not leave the + * switch busy or hide the change's failure. + */ +export function createPushSwitch(show: (patch: Partial) => void) { + let latest = 0; + let latestChange = 0; + + return { + load(read: () => Promise): void { + const id = ++latest; + read().then( + (state) => { + if (id === latest) show({ state }); + }, + (cause: unknown) => console.warn('Could not read the notification state:', cause instanceof Error ? cause.message : cause), + ); + }, + + change(write: () => Promise): void { + const id = ++latest; + latestChange = id; + show({ busy: true, failed: false }); + write().then( + (state) => { + if (id === latest) show({ state }); + if (id === latestChange) show({ busy: false }); + }, + (cause: unknown) => { + console.warn('Could not change notifications:', cause instanceof Error ? cause.message : cause); + if (id === latestChange) show({ busy: false, failed: true }); + }, + ); + }, + }; +} diff --git a/apps/mobile/src/lib/push.test.ts b/apps/mobile/src/lib/push.test.ts new file mode 100644 index 000000000..929c33558 --- /dev/null +++ b/apps/mobile/src/lib/push.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type Sent = { + name: string; + args: Record; + headers: Record; + signal?: AbortSignal; +}; +type Session = { user: { id: string }; access_token: string } | null; + +const server = vi.hoisted(() => ({ + sent: [] as Sent[], + session: null as Session, + respond: (_request: Sent): Promise<{ error: { message: string } | null }> => Promise.resolve({ error: null }), + storage: new Map(), +})); + +function builder(request: Sent) { + const query = { + setHeader: (name: string, value: string) => ((request.headers[name] = value), query), + abortSignal: (signal: AbortSignal) => ((request.signal = signal), query), + then: (onFulfilled: (value: { error: { message: string } | null }) => T, onRejected?: (cause: unknown) => T) => { + server.sent.push(request); + const aborted = { error: { message: 'AbortError' } }; + if (request.signal?.aborted) return Promise.resolve(aborted).then(onFulfilled, onRejected); + // As with fetch, aborting ends the request for the app; the server may still act on it. + const answer = new Promise<{ error: { message: string } | null }>((resolve, reject) => { + request.signal?.addEventListener('abort', () => resolve(aborted), { once: true }); + server.respond(request).then(resolve, reject); + }); + return answer.then(onFulfilled, onRejected); + }, + }; + return query; +} + +vi.mock('./supabase', () => ({ + supabase: { + auth: { getSession: async () => ({ data: { session: server.session } }) }, + rpc: (name: string, args: Record) => builder({ name, args, headers: {} }), + }, +})); +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: async (key: string) => server.storage.get(key) ?? null, + setItem: async (key: string, value: string) => void server.storage.set(key, value), + removeItem: async (key: string) => void server.storage.delete(key), + }, +})); +vi.mock('./device-storage', () => ({ getInstallId: async () => 'install-1' })); +vi.mock('expo-crypto', () => ({ randomUUID: () => crypto.randomUUID() })); +vi.mock('expo', () => ({ isRunningInExpoGo: () => false })); +vi.mock('expo/fetch', () => ({ fetch: vi.fn() })); +vi.mock('expo-constants', () => ({ default: { easConfig: { projectId: 'project' }, expoConfig: null } })); +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })); +vi.mock('expo-notifications', () => ({ + IosAuthorizationStatus: { PROVISIONAL: 3 }, + setNotificationHandler: () => {}, + getPermissionsAsync: async () => ({ granted: true, canAskAgain: true }), + requestPermissionsAsync: async () => ({ granted: true, canAskAgain: true }), + getExpoPushTokenAsync: async () => ({ data: TOKEN }), +})); + +const TOKEN = 'ExponentPushToken[aaaaaaaaaaaaaaaaaaaaaa]'; +const KEY = 'debatelab.push-registration'; +const ACCOUNT_A = { user: { id: 'user-a' }, access_token: 'token-a' }; +const ACCOUNT_B = { user: { id: 'user-b' }, access_token: 'token-b' }; + +const { enablePush, forgetPushOnSignOut, readPushState, refreshPush } = await import('./push'); + +/** A small model of the database: rows, and the registrations a release withdrew. */ +function database() { + const db = { rows: [] as { token: unknown; userId: string; registration: unknown }[], withdrawn: new Set() }; + const callers: Record = { 'Bearer token-a': 'user-a', 'Bearer token-b': 'user-b' }; + const apply = (request: Sent) => { + const caller = callers[request.headers.Authorization]; + const args = request.args; + if (request.name === 'register_push_token') { + if (db.withdrawn.has(args.p_registration)) return { error: { message: 'withdrawn' } }; + db.rows = db.rows.filter((row) => row.token !== args.p_token); + db.rows.push({ token: args.p_token, userId: caller, registration: args.p_registration }); + } else if (request.name === 'release_push_token') { + if (args.p_registration) db.withdrawn.add(args.p_registration); + db.rows = db.rows.filter((row) => row.registration !== args.p_registration); + } + return { error: null }; + }; + return Object.assign(db, { apply }); +} + +function deferred() { + let resolve!: (value: { error: { message: string } | null }) => void; + const promise = new Promise<{ error: { message: string } | null }>((res) => (resolve = res)); + return { promise, resolve }; +} + +describe('push token removal on sign-out', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + server.sent.length = 0; + server.storage.clear(); + server.session = ACCOUNT_A; + server.respond = () => Promise.resolve({ error: null }); + }); + afterEach(() => vi.useRealTimers()); + + it('removes a registration that lands after its account signed out, with that account\'s token', async () => { + const registration = deferred(); + server.respond = (request) => (request.name === 'register_push_token' ? registration.promise : Promise.resolve({ error: null })); + const enabled = enablePush('user-a', 'en'); + await vi.waitFor(() => expect(server.sent.map((request) => request.name)).toContain('register_push_token')); + + // Sign-out stops waiting after five seconds while the registration is still out. + const signOut = forgetPushOnSignOut('user-a', 'token-a'); + await vi.advanceTimersByTimeAsync(5_000); + await signOut; + server.session = null; + + registration.resolve({ error: null }); + await expect(enabled).resolves.toBe('on'); + await vi.waitFor(() => expect(server.storage.has(KEY)).toBe(false)); + const registered = server.sent.find((request) => request.name === 'register_push_token'); + const removal = server.sent.find((request) => request.name === 'release_push_token'); + expect(removal).toMatchObject({ + args: { p_installation_id: 'install-1', p_registration: registered?.args.p_registration }, + headers: { Authorization: 'Bearer token-a' }, + }); + expect(removal?.signal?.aborted).toBe(false); + }); + + it('keeps the registration when the removal fails, and the next account releases the token', async () => { + server.storage.set(KEY, JSON.stringify({ userId: 'user-a', token: TOKEN, id: 'registration-a', confirmed: true })); + server.respond = () => Promise.resolve({ error: { message: 'offline' } }); + await forgetPushOnSignOut('user-a', 'token-a'); + expect(server.storage.has(KEY)).toBe(true); + + server.session = ACCOUNT_B; + server.respond = () => Promise.resolve({ error: null }); + await refreshPush('user-b', 'en'); + expect(server.sent.at(-1)).toMatchObject({ + name: 'release_push_token', + args: { p_registration: 'registration-a' }, + headers: { Authorization: 'Bearer token-b' }, + }); + expect(server.storage.has(KEY)).toBe(false); + }); + + it('keeps a registration that commits after sign-out removed the rows from reaching the next account', async () => { + const db = database(); + // The registration reaches the database but commits only when the test says so. + const commit = deferred(); + server.respond = (request) => + request.name === 'register_push_token' ? commit.promise.then(() => db.apply(request)) : Promise.resolve(db.apply(request)); + + const enabled = enablePush('user-a', 'en'); + await vi.waitFor(() => expect(server.sent.map((request) => request.name)).toContain('register_push_token')); + // The app gives up on it; cancelling the request does not cancel the statement. + const failed = expect(enabled).rejects.toThrow(); + await vi.advanceTimersByTimeAsync(20_000); + await failed; + + await forgetPushOnSignOut('user-a', 'token-a'); + server.session = null; + commit.resolve({ error: null }); + await vi.advanceTimersByTimeAsync(0); + + server.session = ACCOUNT_B; + await refreshPush('user-b', 'en'); + expect(db.rows).toEqual([]); + }); + + it('registers under a new ID after a release that never answered, so the late release leaves it alone', async () => { + const db = database(); + server.storage.set(KEY, JSON.stringify({ userId: 'user-a', token: TOKEN, id: 'registration-a', confirmed: true })); + db.rows.push({ token: TOKEN, userId: 'user-a', registration: 'registration-a' }); + // Sign-out's release reaches the database only when the test says so. + const late = deferred(); + let held = false; + server.respond = (request) => { + if (request.name !== 'release_push_token' || held) return Promise.resolve(db.apply(request)); + held = true; + return late.promise.then(() => db.apply(request)); + }; + const signOut = forgetPushOnSignOut('user-a', 'token-a'); + await vi.advanceTimersByTimeAsync(20_000); + await signOut; + + // The same account signs in again and turns notifications on; then the old release lands. + await expect(enablePush('user-a', 'en')).resolves.toBe('on'); + late.resolve({ error: null }); + await vi.advanceTimersByTimeAsync(0); + + expect(db.rows).toMatchObject([{ token: TOKEN, userId: 'user-a' }]); + expect(db.rows[0]?.registration).not.toBe('registration-a'); + await expect(readPushState('user-a')).resolves.toBe('on'); + }); + + it('removes a first registration whose answer was lost', async () => { + server.respond = (request) => + Promise.resolve({ error: request.name === 'register_push_token' ? { message: 'Network request failed' } : null }); + await expect(enablePush('user-a', 'en')).rejects.toThrow('Network request failed'); + await expect(readPushState('user-a')).resolves.toBe('off'); + + // The server may have stored the row before the answer was lost. + await forgetPushOnSignOut('user-a', 'token-a'); + expect(server.sent.at(-1)).toMatchObject({ name: 'release_push_token', headers: { Authorization: 'Bearer token-a' } }); + expect(server.storage.has(KEY)).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/push.ts b/apps/mobile/src/lib/push.ts new file mode 100644 index 000000000..40b1406f2 --- /dev/null +++ b/apps/mobile/src/lib/push.ts @@ -0,0 +1,250 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { AppLanguage } from '@debatelab/core/languages'; +import { isRunningInExpoGo } from 'expo'; +import Constants from 'expo-constants'; +import * as Crypto from 'expo-crypto'; +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; + +import { untilAborted } from './ai-request'; +import { getInstallId } from './device-storage'; +import { createSerialQueue } from './serial-queue'; +import { supabase } from './supabase'; + +/** + * Push notifications through the Expo push service. The server sends them + * (supabase/functions/_shared/push.ts); this file asks for permission, files + * the phone's Expo push token under the signed-in user with + * register_push_token, and removes it again on sign-out. + * + * Every registration carries the ID of the local registration record, and + * release_push_token withdraws that ID and deletes only the rows it wrote. The + * server refuses a registration whose ID was withdrawn, so a registration the + * app gave up on cannot land after the release: cancelling a request does not + * cancel its database statement. Once a release is sent for an ID, the next + * registration gets a new one, so a release that arrives late never deletes a + * newer registration's row. Sign-out releases with that account's own access + * token, which keeps working until it expires. The local record is cleared + * only once a release succeeded; until then the next change on this phone + * releases it again before doing anything else with push. + * + * - unavailable: this build cannot get an Expo push token. Expo Go on Android + * has no remote push since SDK 53, and every build needs the app's EAS + * project ID (`extra.eas.projectId` in app.config.ts), which is not set yet. + * - blocked: iOS Settings turned notifications off for the app. + * - off / on: this account on this phone. + */ +export type PushState = 'unavailable' | 'blocked' | 'off' | 'on'; + +// The token this install registered, for whom, and the registration's ID. +// `confirmed` stays false until the server confirms the first registration, +// so a registration whose answer never came is still known here and gets +// released. `released` is set before a release is sent and the ID is never +// registered again after it. +const REGISTRATION_KEY = 'debatelab.push-registration'; +// register_push_token's error for a registration whose ID was released. +const WITHDRAWN = '55000'; +// How long sign-out waits; the release itself carries on in the background. +const SIGN_OUT_WAIT_MS = 5_000; +// How long one round of push requests may run, so a hung call cannot hold the queue. +const REQUEST_BUDGET_MS = 20_000; + +type Registration = { userId: string; token: string; id: string; confirmed: boolean; released: boolean }; + +// One change at a time, so sign-out's release runs after a registration in +// flight instead of before it, and the next account never inherits the row. +const serial = createSerialQueue(); + +// Shown while the app is open too; without a handler iOS drops it. +Notifications.setNotificationHandler({ + handleNotification: async () => ({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: false, shouldSetBadge: false }), +}); + +function projectId(): string | undefined { + const fromConfig: unknown = Constants.expoConfig?.extra?.eas?.projectId; + return Constants.easConfig?.projectId ?? (typeof fromConfig === 'string' && fromConfig ? fromConfig : undefined); +} + +function available(): boolean { + return projectId() !== undefined && !(Platform.OS === 'android' && isRunningInExpoGo()); +} + +function allowed(permissions: Notifications.NotificationPermissionsStatus): boolean { + return permissions.granted || permissions.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; +} + +async function readRegistration(): Promise { + try { + const value: unknown = JSON.parse((await AsyncStorage.getItem(REGISTRATION_KEY)) ?? 'null'); + if (typeof value !== 'object' || value === null) return null; + const { userId, token, id, confirmed, released } = value as Record; + if (typeof userId !== 'string' || typeof token !== 'string' || typeof id !== 'string') return null; + return { userId, token, id, confirmed: confirmed === true, released: released === true }; + } catch { + return null; + } +} + +function writeRegistration(registration: Registration): Promise { + return AsyncStorage.setItem(REGISTRATION_KEY, JSON.stringify(registration)); +} + +/** Runs network work under one deadline, aborting its requests when it passes. */ +async function withDeadline(work: (signal: AbortSignal) => Promise): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_BUDGET_MS); + try { + return await untilAborted(work(controller.signal), controller.signal); + } finally { + clearTimeout(timeout); + } +} + +/** The access token of the signed-in account, if it is still `userId`, so a request cannot run as the next account. */ +async function accessTokenOf(userId: string, signal: AbortSignal): Promise { + const { data } = await untilAborted(supabase.auth.getSession(), signal); + if (data.session?.user.id !== userId) throw new Error('The account changed before the request was sent.'); + return data.session.access_token; +} + +async function register(userId: string, language: AppLanguage, signal: AbortSignal): Promise { + const token = (await untilAborted(Notifications.getExpoPushTokenAsync({ projectId: projectId() }), signal)).data; + // register_push_token files the token under whoever sent the request; send it as this user. + const accessToken = await accessTokenOf(userId, signal); + const installationId = await untilAborted(getInstallId(), signal); + // Callers release any other registration first, so one recorded here is this user's own and was never released. + const previous = await untilAborted(readRegistration(), signal); + const registration = + previous?.userId === userId + ? { ...previous, token } + : { userId, token, id: Crypto.randomUUID(), confirmed: false, released: false }; + // Recorded before asking, so a release withdraws this registration even if its answer is lost. + await untilAborted(writeRegistration(registration), signal); + const { error } = await supabase + .rpc('register_push_token', { + p_token: token, + p_platform: Platform.OS, + p_locale: language, + p_installation_id: installationId, + p_registration: registration.id, + }) + .setHeader('Authorization', `Bearer ${accessToken}`) + .abortSignal(signal); + // A release went through but its answer was lost, so this registration is already off. + if (error?.code === WITHDRAWN) await untilAborted(AsyncStorage.removeItem(REGISTRATION_KEY), signal); + if (error) throw new Error(error.message); + if (!registration.confirmed) await untilAborted(writeRegistration({ ...registration, confirmed: true }), signal); +} + +/** + * Withdraws the registration recorded here, whoever made it, and deletes the + * rows it wrote, then forgets it. Any signed-in account may release: the + * registration ID is the proof. + */ +async function release(accessToken: string, signal: AbortSignal): Promise { + const registration = await untilAborted(readRegistration(), signal); + const installationId = await untilAborted(getInstallId(), signal); + // Marked before sending: this release may still arrive after a newer registration, so that one needs a new ID. + if (registration && !registration.released) await untilAborted(writeRegistration({ ...registration, released: true }), signal); + const { error } = await supabase + .rpc('release_push_token', { + p_installation_id: installationId, + p_registration: registration?.id ?? null, + }) + .setHeader('Authorization', `Bearer ${accessToken}`) + .abortSignal(signal); + if (error) throw new Error(error.message); + await untilAborted(AsyncStorage.removeItem(REGISTRATION_KEY), signal); +} + +/** + * Before anything else with push on this phone, releases a registration this + * user should not hold: one another account left because its release on + * sign-out failed, a first registration of this user's whose answer never + * came, since the switch showed it as off, or one whose release was sent but + * not confirmed. + */ +async function releaseStale(userId: string, signal: AbortSignal): Promise { + const registration = await untilAborted(readRegistration(), signal); + if (!registration || (registration.userId === userId && registration.confirmed && !registration.released)) return; + await release(await accessTokenOf(userId, signal), signal); +} + +/** Queued behind any change in flight, so a read during a change reports where it ended. */ +export function readPushState(userId: string): Promise { + return serial(async () => { + if (!available()) return 'unavailable'; + const [permissions, registration] = await Promise.all([Notifications.getPermissionsAsync(), readRegistration()]); + if (registration?.userId === userId && registration.confirmed && !registration.released) { + return allowed(permissions) ? 'on' : 'blocked'; + } + return !allowed(permissions) && !permissions.canAskAgain ? 'blocked' : 'off'; + }); +} + +/** Asks for permission if needed, then files the token under this user. */ +export function enablePush(userId: string, language: AppLanguage): Promise { + return serial(async () => { + if (!available()) return 'unavailable'; + // The iOS prompt waits for the user, so it stays outside the deadline. + let permissions = await Notifications.getPermissionsAsync(); + if (!allowed(permissions) && permissions.canAskAgain) permissions = await Notifications.requestPermissionsAsync(); + if (!allowed(permissions)) return 'blocked'; + await withDeadline(async (signal) => { + await releaseStale(userId, signal); + await register(userId, language, signal); + }); + return 'on'; + }); +} + +/** Stops this user's notifications on this phone. Throws if the rows could not be removed, so the switch stays on. */ +export function disablePush(userId: string): Promise { + return serial(() => withDeadline(async (signal) => release(await accessTokenOf(userId, signal), signal))); +} + +/** + * On launch and when the language changes: registers again, because Expo can + * hand out a new token and the server writes in the token's language. It + * first releases a registration this user should not hold (see releaseStale). + */ +export function refreshPush(userId: string, language: AppLanguage): Promise { + return serial(() => + withDeadline(async (signal) => { + await releaseStale(userId, signal); + const registration = await untilAborted(readRegistration(), signal); + if (registration?.userId !== userId) return; + if (!available() || !allowed(await Notifications.getPermissionsAsync())) return; + await register(userId, language, signal); + }), + ); +} + +/** + * Called by sign-out with the account's access token, before the session is + * removed, so the next person on this phone never gets this account's + * notifications. The release waits for a registration in flight and carries + * on after sign-out returns: the access token stays valid until it expires. + * Sign-out waits for it at most five seconds and this never throws. If the + * release fails, the registration stays recorded and the next account on this + * phone releases it (see releaseStale). + */ +export async function forgetPushOnSignOut(userId: string, accessToken: string): Promise { + const forget = serial(() => + withDeadline(async (signal) => { + const registration = await untilAborted(readRegistration(), signal); + if (registration?.userId === userId) await release(accessToken, signal); + }), + ).catch((cause: unknown) => { + console.warn('Could not release the push token on sign-out:', cause instanceof Error ? cause.message : cause); + }); + const wait = new AbortController(); + const timeout = setTimeout(() => wait.abort(), SIGN_OUT_WAIT_MS); + try { + await untilAborted(forget, wait.signal); + } catch { + // Still waiting for a registration in flight; the release runs once it lands. + } finally { + clearTimeout(timeout); + } +} diff --git a/apps/mobile/src/lib/push.web.ts b/apps/mobile/src/lib/push.web.ts new file mode 100644 index 000000000..d0d05cf1c --- /dev/null +++ b/apps/mobile/src/lib/push.web.ts @@ -0,0 +1,35 @@ +import type { AppLanguage } from '@debatelab/core/languages'; + +import type { PushState } from './push'; + +export type { PushState } from './push'; + +/** + * The web preview cannot receive push. It acts as a phone that has not been + * asked yet and turns on without registering anything, so the screenshot + * script can show both states of the switch. The phone app uses push.ts. + */ +let enabledFor: string | null = null; + +export function readPushState(userId: string): Promise { + return Promise.resolve(enabledFor === userId ? 'on' : 'off'); +} + +export function enablePush(userId: string, _language: AppLanguage): Promise { + enabledFor = userId; + return Promise.resolve('on'); +} + +export function disablePush(_userId: string): Promise { + enabledFor = null; + return Promise.resolve(); +} + +export function refreshPush(_userId: string, _language: AppLanguage): Promise { + return Promise.resolve(); +} + +export function forgetPushOnSignOut(_userId: string, _accessToken: string): Promise { + enabledFor = null; + return Promise.resolve(); +} diff --git a/package-lock.json b/package-lock.json index 58f804f3f..4ae582e77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -139,6 +139,7 @@ "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", "expo-network": "~57.0.2", + "expo-notifications": "~57.0.22", "expo-print": "~57.0.2", "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", @@ -546,6 +547,15 @@ } } }, + "apps/mobile/node_modules/expo-application": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.3.tgz", + "integrity": "sha512-t/CxeLio0NjY9/cOak1tQp62/hAUjzJxmhR4rVUR7K5bb9YoEtfl1ZekTn+Iuh+dYOMYzF0l72ODMSmY3CR1NA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "apps/mobile/node_modules/expo-asset": { "version": "57.0.19", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.19.tgz", @@ -585,9 +595,9 @@ } }, "apps/mobile/node_modules/expo-constants": { - "version": "57.0.21", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.21.tgz", - "integrity": "sha512-D5fV2GELYGbNkoiEVZZp5zrUXZDaAx6ZzErDzGirm3jCjOhaE8iU/GyJiB0ZQE+K4Bg8gY82nIQ/RZR8CJLVjA==", + "version": "57.0.22", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.22.tgz", + "integrity": "sha512-+mBjnzMWMKIrQa7BP7LTDRC0W7cI+q12/sZns4HzVQJGzgGoAdRJSTEKiObxIw/3EswKXlESkRob8upiL95Q4A==", "license": "MIT", "dependencies": { "@expo/env": "~2.4.3" @@ -803,6 +813,24 @@ "react": "*" } }, + "apps/mobile/node_modules/expo-notifications": { + "version": "57.0.23", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.23.tgz", + "integrity": "sha512-KdHROY5tvbzlDJImJKF91F8GRpV4hP+RLPC7NJgiFNyzb0clETpV0hJNdbFrfyubZf+cqx88+WuwFsH9Yl+kzg==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.11.6", + "abort-controller": "^3.0.0", + "badgin": "^1.1.5", + "expo-application": "~57.0.3", + "expo-constants": "~57.0.22" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "apps/mobile/node_modules/expo-print": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-print/-/expo-print-57.0.2.tgz", @@ -10124,6 +10152,12 @@ "@babel/plugin-syntax-flow": "^7.12.1" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", diff --git a/package.json b/package.json index 98b37b6d0..afca36cf6 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:collaboration": "vitest run --config services/collaboration/vitest.config.ts", "start:collaboration": "tsx services/collaboration/src/index.ts", "test:watch": "vitest", - "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY,APP_STORE_BUNDLE_ID,APP_STORE_ENVIRONMENTS,APP_STORE_APP_APPLE_ID --allow-net=127.0.0.1 supabase/functions/", + "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY,APP_STORE_BUNDLE_ID,APP_STORE_ENVIRONMENTS,APP_STORE_APP_APPLE_ID,EXPO_ACCESS_TOKEN --allow-net=127.0.0.1 supabase/functions/", "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh", "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", "test:e2e:smoke": "playwright test --project=desktop-chromium --grep @smoke", diff --git a/src/locales/en.json b/src/locales/en.json index 38a528392..24eaa7d69 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2446,7 +2446,15 @@ "you": { "usage": "{{used}} of {{limit}} AI uses today", "loadFailed": "Could not load your subscription.", - "saveFailed": "Could not save your settings. Try again." + "saveFailed": "Could not save your settings. Try again.", + "notifications": { + "title": "Notifications", + "description": "Get a notification on this phone when a sparring round is scored.", + "blocked": "Notifications are off for this app in iOS Settings.", + "openSettings": "Open Settings", + "unavailable": "This version of the app cannot receive notifications yet.", + "failed": "Could not change notifications. Try again." + } }, "deleteAccount": { "failedHint": "Your account is still here. Check your connection and try again." diff --git a/src/locales/es.json b/src/locales/es.json index f81d1e003..c794b844b 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2446,7 +2446,15 @@ "you": { "usage": "{{used}} de {{limit}} usos de IA hoy", "loadFailed": "No se pudo cargar tu suscripción.", - "saveFailed": "No se pudieron guardar tus ajustes. Inténtalo de nuevo." + "saveFailed": "No se pudieron guardar tus ajustes. Inténtalo de nuevo.", + "notifications": { + "title": "Notificaciones", + "description": "Recibe una notificación en este teléfono cuando se puntúe una ronda de sparring.", + "blocked": "Las notificaciones de esta app están desactivadas en los Ajustes de iOS.", + "openSettings": "Abrir Ajustes", + "unavailable": "Esta versión de la app todavía no puede recibir notificaciones.", + "failed": "No se pudieron cambiar las notificaciones. Inténtalo de nuevo." + } }, "deleteAccount": { "failedHint": "Tu cuenta sigue activa. Comprueba tu conexión e inténtalo de nuevo." diff --git a/src/locales/hu.json b/src/locales/hu.json index 3feb0fc63..71de44e2c 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2446,7 +2446,15 @@ "you": { "usage": "Ma {{used}} / {{limit}} MI-használat", "loadFailed": "Nem sikerült betölteni az előfizetésedet.", - "saveFailed": "Nem sikerült menteni a beállításokat. Próbáld újra." + "saveFailed": "Nem sikerült menteni a beállításokat. Próbáld újra.", + "notifications": { + "title": "Értesítések", + "description": "Értesítést kapsz erre a telefonra, amikor elkészül egy sparring forduló értékelése.", + "blocked": "Az iOS Beállításokban ki vannak kapcsolva az alkalmazás értesítései.", + "openSettings": "Beállítások megnyitása", + "unavailable": "Az alkalmazás ezen verziója még nem tud értesítéseket fogadni.", + "failed": "Nem sikerült módosítani az értesítéseket. Próbáld újra." + } }, "deleteAccount": { "failedHint": "A fiókod megmaradt. Ellenőrizd a kapcsolatot, és próbáld újra." diff --git a/supabase/README.md b/supabase/README.md index 2615548be..2adf66ed5 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -28,7 +28,7 @@ supabase/tests/ pgTAP database and policy tests ### AI and practice -- `ai`: authenticated non-streaming endpoint for generate, attack, brainstorm, factoids, Ask, Crossfire, and sparring. It enforces supported providers and controls, access limits, research bounds, output validation, usage accounting, and sparring integrity. +- `ai`: authenticated non-streaming endpoint for generate, attack, brainstorm, factoids, Ask, Crossfire, and sparring. It enforces supported providers and controls, access limits, research bounds, output validation, usage accounting, and sparring integrity. Once a sparring scorecard is saved it sends a `sparring_scored` push to the scorer's phones (see [Push notifications](#push-notifications)). - `ai-stream`: authenticated server-sent events endpoint for Ask. It emits deltas, a final event, or an error and records usage. - `voice`: authenticated text-to-speech for the opponent in voice rounds. It accepts one segment of at most 1,000 characters with the token `ai` signed for it, so it speaks only server-written text. It answers `voice_unavailable` without `ELEVENLABS_API_KEY`, enforces the per-user daily voice cap through `consume_daily_quota` under the `voice` key, calls ElevenLabs (`eleven_v4` for sparring, `eleven_v4_turbo` for Crossfire), and records each request in `ai_requests` as `voice_sparring` or `voice_crossfire`. Every failure is a JSON error with a code, and the browser then reads the reply with its own voice. @@ -81,10 +81,28 @@ Other shared modules own: - crossfire response validation; - audio-tag handling and signed voice tokens for voice rounds (`voice-tags.ts`, which the browser also imports, and `voice-token.ts`); - the pinned OpenRouter Jev Decisions client, automatic model routing, semantic source decisions, and Crossfire signals; -- announcement input validation. +- announcement input validation; +- push notifications through the Expo push service (`push.ts`). Move code into `_shared` only when multiple functions use the same boundary or policy. Feature-specific behavior stays with its handler. +## Push notifications + +The iOS app files its Expo push token through the `register_push_token` RPC (`20261014130000_push_tokens.sql`). The function writes only the caller's `auth.uid()`, moves a token to the current account when a second account signs in on the same phone, keeps one token per install and ten per account (one registration per account at a time, under an advisory lock), and turns a disabled token back on. Turning notifications off and signing out call `release_push_token` (sign-out with the account's own access token); the app keeps its local record until a release succeeds, and if it never does, the next account on the phone releases it. Each registration carries the ID of the app's local record, and a release withdraws that ID (`20261014200000_push_registration_withdrawal.sql`). Cancelling a request does not cancel its database statement, so a registration the app gave up on could otherwise commit after the release and keep the signed-out account's notifications on the phone. Registration refuses a withdrawn ID, and both functions hold the ID's advisory lock until they commit, so whichever runs second sees the other. `release_push_token` deletes only the rows its registration wrote, whichever account holds them, and the caller's own rows from the install that carry no registration ID (`20261014220000_push_release_by_registration.sql`); knowing the registration ID is the proof. The app never registers an ID again once it has sent a release for it, so a release that arrives late cannot delete a newer registration of the same token, whether the next account's or the same account's. Clients can read and delete only their own `push_tokens` rows and cannot insert or update them directly. `push_tickets` is service-role only and carries no user ID; deleting a token or an account deletes its tickets. + +[`functions/_shared/push.ts`](functions/_shared/push.ts) sends through `https://exp.host/--/api/v2/push/send`, so the same code serves Expo Go, development and store builds. It reads tokens with the service role for the user IDs named by each notification, builds every message from the notification of the user who owns the token row, and drops rows for anyone else. Messages carry fixed, localized copy (`en`, `es`, `hu`, from the token's locale) and IDs only, never debate content, because they show on a lock screen. It sends in batches of 100, stores the tickets, turns a token off when a ticket or receipt says `DeviceNotRegistered`, and never throws into the request that triggered it. There is no scheduled receipts job: each send first reads the receipts that are at least 15 minutes old and forgets tickets older than Expo's 24-hour receipt window. + +Wired triggers: + +- `sparring_scored`: the `ai` function, after the scorecard is saved, through `EdgeRuntime.waitUntil` so the response does not wait for Expo. + +Not wired yet: + +- `trial_ending`: needs a scheduled job. `user_settings.trial_ends_at` holds the date, and no migration schedules anything yet. A daily pg_cron and pg_net call, or a VPS timer, would call a service-role path that selects users whose trial ends within a day and has not been notified, records that, and calls `deliverPush`. +- `announcements`: the hook point is `create-announcement` in [`functions/admin/index.ts`](functions/admin/index.ts). It needs a store method for `target_type = 'all'`, has to respect `starts_at` (a scheduled announcement sends when it starts, not when it is created) and `target_user_ids`, and should use fixed copy that points into the app rather than the administrator's title, under the lock-screen rule above. + +Deploying push to a project is the migration first, then the `ai` function; neither needs a new secret. `EXPO_ACCESS_TOKEN` is optional and unset: it is needed only if enhanced push security is turned on for the Expo project. A token reaches a phone only once the app has an EAS project ID; see [`../apps/mobile/AGENTS.md`](../apps/mobile/AGENTS.md). + ## Authentication boundary Several entries in [`config.toml`](config.toml) set `verify_jwt = false`. This does not make those functions public. Their handlers perform their own request validation because they need one of these patterns: @@ -130,6 +148,8 @@ The stack begins at port `54321` and is not parallel-safe across worktrees. Coor The database gate proves migration replay and pgTAP assertions locally. It does not prove staging or production state. +`20261014130000_push_tokens.sql` is additive: it creates `push_tokens`, `push_tickets`, `register_push_token` and `release_push_token`. `20261014200000_push_registration_withdrawal.sql` adds `push_tokens.registration_id` and the server-only `push_withdrawn_registrations`, and replaces both functions with versions that take the registration ID. `20261014220000_push_release_by_registration.sql` replaces `release_push_token(text, uuid, uuid)` with `release_push_token(uuid, uuid)`, which no longer deletes by token. See [Push notifications](#push-notifications). + `20261014123154_app_store_entitlements.sql` is additive: it creates `app_store_products` (with placeholder product ids), `app_store_subscriptions` (users read their own, only the service role writes) and `app_store_notifications` (service role only), `effective_entitlement()`, and `apply_app_store_transaction()` (service role only), and adds the App Store flag to `account_deletion_plan`. `supabase/tests/app_store_entitlements_test.sql` covers the tier rules, update ordering, idempotency and isolation. Apply it before anything that calls `effective_entitlement` ships to a project: the AI and voice checks fail closed and the website's plan read fails without it, so every signed-in user would lose AI access. Order: the migration, then `ai`, `ai-stream`, `voice` and `account`, then the frontend (merging to `main` ships it and starts the staging sync). The two App Store functions can follow later. `20260927170000_debate_context_pins.sql` is additive: it creates `debate_context_pins`, the saved results a student pins per debate, with RLS and a composite key to the owner's debate. @@ -173,6 +193,7 @@ Document names, never values. Depending on deployed features, server-side config - `WORKSPACE_INVITE_MAILER_URL`, `WORKSPACE_INVITE_MAILER_TOKEN`, and `WORKSPACE_INVITE_BASE_URL` for the operator-supplied email relay, plus `WORKSPACE_INVITE_ALLOWED_ORIGINS` for an exact preview origin; - sparring integrity material, which also signs voice tokens; - Google export configuration; +- `EXPO_ACCESS_TOKEN`, optional and unset, only if enhanced push security is on for the Expo project; - allowed origins and environment-specific application URLs. Supabase injects `SUPABASE_URL`, `SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_KEY`. Every deployed project, staging included, also needs: diff --git a/supabase/functions/_shared/push.test.ts b/supabase/functions/_shared/push.test.ts new file mode 100644 index 000000000..15306bc6d --- /dev/null +++ b/supabase/functions/_shared/push.test.ts @@ -0,0 +1,235 @@ +import { assert, assertEquals } from "jsr:@std/assert@1.0.19"; +import { + checkReceipts, + deliverPush, + EXPO_PUSH_RECEIPTS_URL, + EXPO_PUSH_SEND_URL, + type ExpoMessage, + type PushStore, + type PushTicketRow, + type PushTokenRow, + sendPush, +} from "./push.ts"; + +const ALICE = "a1111111-1111-4111-8111-111111111111"; +const BOB = "b2222222-2222-4222-8222-222222222222"; +const MALLORY = "c3333333-3333-4333-8333-333333333333"; + +function token(name: string): string { + return `ExponentPushToken[${name.padEnd(22, "x")}]`; +} + +type MemoryStore = PushStore & { + tokens: Array; + tickets: PushTicketRow[]; + requestedUsers: string[][]; +}; + +/** + * An in-memory store. `leak` makes enabledTokens also return rows for users + * nobody asked for, the way a broken query would, so the sender's own check is tested. + */ +function memoryStore(rows: PushTokenRow[], options: { leak?: boolean } = {}): MemoryStore { + const store: MemoryStore = { + tokens: rows.map((row) => ({ ...row, enabled: true })), + tickets: [], + requestedUsers: [], + enabledTokens(userIds) { + store.requestedUsers.push(userIds); + return Promise.resolve(store.tokens.filter((row) => row.enabled && (options.leak || userIds.includes(row.user_id)))); + }, + disableTokens(tokens) { + for (const row of store.tokens) if (tokens.includes(row.token)) row.enabled = false; + return Promise.resolve(); + }, + saveTickets(tickets) { + store.tickets.push(...tickets.map((ticket) => ({ ...ticket, created_at: new Date().toISOString() }))); + return Promise.resolve(); + }, + ticketsSentBefore(cutoff, limit) { + return Promise.resolve(store.tickets.filter((ticket) => Date.parse(ticket.created_at) < cutoff.getTime()).slice(0, limit)); + }, + deleteTickets(ids) { + store.tickets = store.tickets.filter((ticket) => !ids.includes(ticket.id)); + return Promise.resolve(); + }, + }; + return store; +} + +type ExpoCall = { url: string; headers: Headers; body: unknown }; + +/** A fake Expo push service. `ticketFor` decides each message's ticket. */ +function fakeExpo(handlers: { + ticketFor?: (message: ExpoMessage, index: number) => unknown; + receipts?: Record; + sendStatus?: number; +} = {}) { + const calls: ExpoCall[] = []; + let next = 0; + const fetch = ((input: Request | URL | string, init?: RequestInit) => { + const url = String(input); + const body = JSON.parse(String(init?.body)); + calls.push({ url, headers: new Headers(init?.headers), body }); + if (url === EXPO_PUSH_SEND_URL) { + if (handlers.sendStatus) return Promise.resolve(new Response("busy", { status: handlers.sendStatus })); + const data = (body as ExpoMessage[]).map((message, index) => + handlers.ticketFor?.(message, index) ?? { status: "ok", id: `ticket-${++next}` } + ); + return Promise.resolve(Response.json({ data })); + } + if (url === EXPO_PUSH_RECEIPTS_URL) { + const ids = (body as { ids: string[] }).ids; + const data = Object.fromEntries(ids.flatMap((id) => (handlers.receipts?.[id] ? [[id, handlers.receipts[id]]] : []))); + return Promise.resolve(Response.json({ data })); + } + throw new Error(`unexpected request to ${url}`); + }) as typeof globalThis.fetch; + return { fetch, calls, sends: () => calls.filter((call) => call.url === EXPO_PUSH_SEND_URL) }; +} + +Deno.test("each user's tokens get only that user's notification, in that token's language", async () => { + const store = memoryStore([ + { token: token("alice-phone"), user_id: ALICE, locale: "hu" }, + { token: token("alice-ipad"), user_id: ALICE, locale: "en" }, + { token: token("bob-phone"), user_id: BOB, locale: "es" }, + { token: token("mallory-phone"), user_id: MALLORY, locale: "en" }, + ], { leak: true }); + const expo = fakeExpo(); + + const summary = await sendPush(store, [ + { kind: "sparring_scored", userId: ALICE, sessionId: "alice-session" }, + { kind: "sparring_scored", userId: BOB, sessionId: "bob-session" }, + ], { fetch: expo.fetch }); + + const sent = expo.sends().flatMap((call) => call.body as ExpoMessage[]); + assertEquals(summary.messages, 3); + assertEquals( + sent.map((message) => [message.to, message.data.sessionId, message.title]), + [ + [token("alice-phone"), "alice-session", "Elkészült a forduló értékelése"], + [token("alice-ipad"), "alice-session", "Your round is scored"], + [token("bob-phone"), "bob-session", "Tu ronda ya está puntuada"], + ], + ); + assert(!sent.some((message) => message.to === token("mallory-phone")), "a token nobody asked for got a message"); + assertEquals(store.requestedUsers, [[ALICE, BOB]]); + assertEquals(Object.keys(sent[0].data).sort(), ["kind", "sessionId"]); +}); + +Deno.test("messages go out in batches of 100 and every ticket is kept with its token", async () => { + const rows = Array.from({ length: 250 }, (_, index) => ({ + token: token(`user${index}`), + user_id: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + locale: "en", + })); + const store = memoryStore(rows); + const expo = fakeExpo(); + + const summary = await sendPush( + store, + rows.map((row) => ({ kind: "sparring_scored" as const, userId: row.user_id, sessionId: `session-${row.user_id}` })), + { fetch: expo.fetch }, + ); + + assertEquals(expo.sends().map((call) => (call.body as unknown[]).length), [100, 100, 50]); + assertEquals(summary, { messages: 250, tickets: 250, disabled: 0, failedBatches: 0 }); + assertEquals(store.tickets.length, 250); + assertEquals(store.tickets[0].token, rows[0].token); + assertEquals(store.tickets[249].token, rows[249].token); +}); + +Deno.test("a DeviceNotRegistered ticket turns the token off; other errors do not", async () => { + const store = memoryStore([ + { token: token("gone"), user_id: ALICE, locale: "en" }, + { token: token("busy"), user_id: ALICE, locale: "en" }, + { token: token("fine"), user_id: ALICE, locale: "en" }, + ]); + const expo = fakeExpo({ + ticketFor: (message) => { + if (message.to === token("gone")) return { status: "error", message: "gone", details: { error: "DeviceNotRegistered" } }; + if (message.to === token("busy")) return { status: "error", message: "slow down", details: { error: "MessageRateExceeded" } }; + return undefined; + }, + }); + + const summary = await sendPush(store, [{ kind: "sparring_scored", userId: ALICE, sessionId: "s" }], { fetch: expo.fetch }); + + assertEquals(summary.disabled, 1); + assertEquals(store.tokens.map((row) => [row.token, row.enabled]), [ + [token("gone"), false], + [token("busy"), true], + [token("fine"), true], + ]); + assertEquals(store.tickets.map((ticket) => ticket.token), [token("fine")]); + + // A disabled token is not sent to again. + const again = fakeExpo(); + await sendPush(store, [{ kind: "sparring_scored", userId: ALICE, sessionId: "s" }], { fetch: again.fetch }); + assert(!again.sends().some((call) => (call.body as ExpoMessage[]).some((message) => message.to === token("gone")))); +}); + +Deno.test("receipts are read only once due, turn off unregistered devices, and expire after a day", async () => { + const now = new Date("2026-10-14T12:00:00.000Z"); + const minutesAgo = (minutes: number) => new Date(now.getTime() - minutes * 60_000).toISOString(); + const store = memoryStore([ + { token: token("gone"), user_id: ALICE, locale: "en" }, + { token: token("fine"), user_id: BOB, locale: "en" }, + ]); + store.tickets = [ + { id: "fresh", token: token("fine"), created_at: minutesAgo(5) }, + { id: "unregistered", token: token("gone"), created_at: minutesAgo(20) }, + { id: "delivered", token: token("fine"), created_at: minutesAgo(30) }, + { id: "pending", token: token("fine"), created_at: minutesAgo(40) }, + { id: "expired", token: token("fine"), created_at: minutesAgo(25 * 60) }, + ]; + const expo = fakeExpo({ + receipts: { + unregistered: { status: "error", message: "gone", details: { error: "DeviceNotRegistered" } }, + delivered: { status: "ok" }, + }, + }); + + const summary = await checkReceipts(store, { fetch: expo.fetch, now: () => now }); + + assertEquals(expo.calls.length, 1); + assertEquals((expo.calls[0].body as { ids: string[] }).ids.sort(), ["delivered", "expired", "pending", "unregistered"]); + assertEquals(summary, { checked: 4, disabled: 1, deleted: 3 }); + assertEquals(store.tickets.map((ticket) => ticket.id).sort(), ["fresh", "pending"]); + assertEquals(store.tokens.find((row) => row.token === token("gone"))?.enabled, false); + assertEquals(store.tokens.find((row) => row.token === token("fine"))?.enabled, true); +}); + +Deno.test("a failed send keeps nothing and never throws to the caller", async () => { + const store = memoryStore([{ token: token("alice"), user_id: ALICE, locale: "en" }]); + const expo = fakeExpo({ sendStatus: 503 }); + const originalError = console.error; + console.error = () => {}; + try { + await deliverPush(store, [{ kind: "sparring_scored", userId: ALICE, sessionId: "s" }], { fetch: expo.fetch }); + } finally { + console.error = originalError; + } + assertEquals(expo.sends().length, 1); + assertEquals(store.tickets, []); + assertEquals(store.tokens[0].enabled, true); +}); + +Deno.test("the access token is sent only when one is configured", async () => { + const store = memoryStore([{ token: token("alice"), user_id: ALICE, locale: "en" }]); + const without = fakeExpo(); + await sendPush(store, [{ kind: "sparring_scored", userId: ALICE, sessionId: "s" }], { fetch: without.fetch }); + assertEquals(without.calls[0].headers.get("authorization"), null); + + const withToken = fakeExpo(); + await sendPush(store, [{ kind: "sparring_scored", userId: ALICE, sessionId: "s" }], { fetch: withToken.fetch, accessToken: "expo-test-token" }); + assertEquals(withToken.calls[0].headers.get("authorization"), "Bearer expo-test-token"); +}); + +Deno.test("nothing is read or sent when no one is named", async () => { + const store = memoryStore([{ token: token("alice"), user_id: ALICE, locale: "en" }]); + const expo = fakeExpo(); + await sendPush(store, [], { fetch: expo.fetch }); + assertEquals(store.requestedUsers, []); + assertEquals(expo.calls, []); +}); diff --git a/supabase/functions/_shared/push.ts b/supabase/functions/_shared/push.ts new file mode 100644 index 000000000..221e8dcf5 --- /dev/null +++ b/supabase/functions/_shared/push.ts @@ -0,0 +1,284 @@ +// Push notifications through the Expo push service (exp.host), which serves +// Expo Go tokens today and development and store builds later. +// +// A notification names one user. Tokens are read for the named users with the +// service role, and every message is built from the notification of the user +// who owns the token row, so one user's content never reaches another user's +// phone. Messages carry fixed copy and IDs only, never debate content, because +// they show on a lock screen. + +import type { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2"; + +export const EXPO_PUSH_SEND_URL = "https://exp.host/--/api/v2/push/send"; +export const EXPO_PUSH_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts"; + +/** Expo accepts at most 100 messages per send request. */ +export const SEND_BATCH_SIZE = 100; +/** Expo accepts at most 1,000 ticket IDs per receipts request. */ +export const RECEIPT_BATCH_SIZE = 1_000; +/** Expo asks senders to wait about 15 minutes before reading a receipt. */ +export const RECEIPT_DELAY_MS = 15 * 60_000; +/** Expo keeps receipts for 24 hours; a ticket older than that has none left to read. */ +export const RECEIPT_TTL_MS = 24 * 60 * 60_000; +const REQUEST_TIMEOUT_MS = 10_000; + +export type PushNotification = { + kind: "sparring_scored"; + userId: string; + sessionId: string; +}; + +type PushLocale = "en" | "es" | "hu"; + +const COPY: Record> = { + sparring_scored: { + en: { title: "Your round is scored", body: "See the judge's feedback in DebateLab." }, + es: { title: "Tu ronda ya está puntuada", body: "Mira los comentarios del juez en DebateLab." }, + hu: { title: "Elkészült a forduló értékelése", body: "Nézd meg a bíró visszajelzését a DebateLabben." }, + }, +}; + +export type PushTokenRow = { token: string; user_id: string; locale: string }; +export type PushTicketRow = { id: string; token: string; created_at: string }; + +/** The database operations the sender needs, all done with the service role. */ +export interface PushStore { + enabledTokens(userIds: string[]): Promise; + disableTokens(tokens: string[]): Promise; + saveTickets(tickets: Array<{ id: string; token: string }>): Promise; + ticketsSentBefore(cutoff: Date, limit: number): Promise; + deleteTickets(ids: string[]): Promise; +} + +export type PushOptions = { + fetch?: typeof fetch; + /** Only needed when "enhanced push security" is on for the Expo project. */ + accessToken?: string; + now?: () => Date; +}; + +export type ExpoMessage = { + to: string; + title: string; + body: string; + sound: "default"; + data: { kind: PushNotification["kind"]; sessionId: string }; +}; + +type ExpoTicket = { status: "ok"; id: string } | { status: "error"; message?: string; details?: { error?: string } }; +type ExpoReceipt = { status: "ok" } | { status: "error"; message?: string; details?: { error?: string } }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function chunks(items: T[], size: number): T[][] { + const result: T[][] = []; + for (let index = 0; index < items.length; index += size) result.push(items.slice(index, index + size)); + return result; +} + +function localeOf(value: string): PushLocale { + return value === "es" || value === "hu" ? value : "en"; +} + +function message(notification: PushNotification, row: PushTokenRow): ExpoMessage { + const copy = COPY[notification.kind][localeOf(row.locale)]; + return { + to: row.token, + title: copy.title, + body: copy.body, + sound: "default", + data: { kind: notification.kind, sessionId: notification.sessionId }, + }; +} + +/** + * One message per notification per enabled token of the user it names. A row + * for any other user is dropped, whatever the store returned. + */ +export function buildMessages(notifications: PushNotification[], rows: PushTokenRow[]): ExpoMessage[] { + const byUser = new Map(); + for (const notification of notifications) { + byUser.set(notification.userId, [...(byUser.get(notification.userId) ?? []), notification]); + } + return rows.flatMap((row) => (byUser.get(row.user_id) ?? []).map((notification) => message(notification, row))); +} + +async function postJson(url: string, body: unknown, options: PushOptions): Promise { + const headers: Record = { + Accept: "application/json", + "Accept-Encoding": "gzip, deflate", + "Content-Type": "application/json", + }; + if (options.accessToken) headers.Authorization = `Bearer ${options.accessToken}`; + const response = await (options.fetch ?? fetch)(url, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error(`Expo push service answered ${response.status}`); + } + return await response.json(); +} + +function isDeviceNotRegistered(result: ExpoTicket | ExpoReceipt): boolean { + return result.status === "error" && result.details?.error === "DeviceNotRegistered"; +} + +export type SendSummary = { messages: number; tickets: number; disabled: number; failedBatches: number }; + +/** Sends the notifications and keeps the tickets for a later receipts check. */ +export async function sendPush(store: PushStore, notifications: PushNotification[], options: PushOptions = {}): Promise { + const summary: SendSummary = { messages: 0, tickets: 0, disabled: 0, failedBatches: 0 }; + const userIds = [...new Set(notifications.map((notification) => notification.userId))]; + if (userIds.length === 0) return summary; + + const messages = buildMessages(notifications, await store.enabledTokens(userIds)); + summary.messages = messages.length; + const tickets: Array<{ id: string; token: string }> = []; + const unregistered = new Set(); + + for (const batch of chunks(messages, SEND_BATCH_SIZE)) { + let data: unknown; + try { + const body = await postJson(EXPO_PUSH_SEND_URL, batch, options); + data = isRecord(body) ? body.data : undefined; + } catch (error) { + console.error("[PUSH] Send failed", error instanceof Error ? error.message : "unknown error"); + summary.failedBatches += 1; + continue; + } + // Tickets come back in the order of the messages sent. + if (!Array.isArray(data) || data.length !== batch.length) { + console.error("[PUSH] Send answered without a ticket per message"); + summary.failedBatches += 1; + continue; + } + data.forEach((value, index) => { + if (!isRecord(value)) return; + const ticket = value as ExpoTicket; + if (ticket.status === "ok" && typeof ticket.id === "string" && ticket.id) { + tickets.push({ id: ticket.id, token: batch[index].to }); + } else if (isDeviceNotRegistered(ticket)) { + unregistered.add(batch[index].to); + } else if (ticket.status === "error") { + console.warn("[PUSH] Message rejected", ticket.details?.error ?? "unknown"); + } + }); + } + + if (tickets.length > 0) await store.saveTickets(tickets); + if (unregistered.size > 0) await store.disableTokens([...unregistered]); + summary.tickets = tickets.length; + summary.disabled = unregistered.size; + return summary; +} + +export type ReceiptSummary = { checked: number; disabled: number; deleted: number }; + +/** + * Reads the receipts of tickets sent at least 15 minutes ago, turns off tokens + * whose device is no longer registered, and forgets tickets whose receipt was + * read or has expired. A ticket with no receipt yet waits for the next check. + */ +export async function checkReceipts(store: PushStore, options: PushOptions = {}): Promise { + const now = (options.now ?? (() => new Date()))(); + const due = await store.ticketsSentBefore(new Date(now.getTime() - RECEIPT_DELAY_MS), RECEIPT_BATCH_SIZE); + const summary: ReceiptSummary = { checked: due.length, disabled: 0, deleted: 0 }; + if (due.length === 0) return summary; + + const body = await postJson(EXPO_PUSH_RECEIPTS_URL, { ids: due.map((ticket) => ticket.id) }, options); + const receipts = isRecord(body) && isRecord(body.data) ? body.data : {}; + const expiredBefore = now.getTime() - RECEIPT_TTL_MS; + const unregistered = new Set(); + const done: string[] = []; + for (const ticket of due) { + const receipt = receipts[ticket.id]; + if (isRecord(receipt)) { + if (isDeviceNotRegistered(receipt as ExpoReceipt)) unregistered.add(ticket.token); + done.push(ticket.id); + } else if (Date.parse(ticket.created_at) < expiredBefore) { + done.push(ticket.id); + } + } + + if (unregistered.size > 0) await store.disableTokens([...unregistered]); + if (done.length > 0) await store.deleteTickets(done); + summary.disabled = unregistered.size; + summary.deleted = done.length; + return summary; +} + +/** + * Checks earlier receipts, then sends. There is no scheduled receipts job yet, + * so each send also settles the receipts that are due. Never throws: a push is + * a courtesy, and must not fail the request that triggered it. + */ +export async function deliverPush(store: PushStore, notifications: PushNotification[], options: PushOptions = {}): Promise { + try { + await checkReceipts(store, options); + } catch (error) { + console.error("[PUSH] Receipts check failed", error instanceof Error ? error.message : "unknown error"); + } + try { + const summary = await sendPush(store, notifications, options); + if (summary.messages > 0) console.log(JSON.stringify({ message: "[PUSH] Send finished", ...summary })); + } catch (error) { + console.error("[PUSH] Send failed", error instanceof Error ? error.message : "unknown error"); + } +} + +function throwOnError(result: { error: { code?: string; message: string } | null }, action: string) { + if (result.error) throw new Error(`${action} failed (${result.error.code ?? "unknown"})`); +} + +/** The PushStore over the service-role client. Never pass a user-scoped client. */ +export function supabasePushStore(client: SupabaseClient): PushStore { + return { + async enabledTokens(userIds) { + const result = await client + .from("push_tokens") + .select("token,user_id,locale") + .in("user_id", userIds) + .eq("enabled", true); + throwOnError(result, "Reading push tokens"); + return (result.data ?? []) as PushTokenRow[]; + }, + async disableTokens(tokens) { + const result = await client + .from("push_tokens") + .update({ enabled: false, updated_at: new Date().toISOString() }) + .in("token", tokens); + throwOnError(result, "Disabling push tokens"); + }, + async saveTickets(tickets) { + // A token can be deleted between the send and here; its tickets have nothing left to check. + const result = await client.from("push_tickets").upsert(tickets, { onConflict: "id", ignoreDuplicates: true }); + if (result.error?.code === "23503") return; + throwOnError(result, "Saving push tickets"); + }, + async ticketsSentBefore(cutoff, limit) { + const result = await client + .from("push_tickets") + .select("id,token,created_at") + .lt("created_at", cutoff.toISOString()) + .order("created_at", { ascending: true }) + .limit(limit); + throwOnError(result, "Reading push tickets"); + return (result.data ?? []) as PushTicketRow[]; + }, + async deleteTickets(ids) { + const result = await client.from("push_tickets").delete().in("id", ids); + throwOnError(result, "Deleting push tickets"); + }, + }; +} + +/** Optional: set only when enhanced push security is on for the Expo project. */ +export function expoAccessToken(): string | undefined { + return Deno.env.get("EXPO_ACCESS_TOKEN")?.trim() || undefined; +} diff --git a/supabase/functions/ai/features/sparring.ts b/supabase/functions/ai/features/sparring.ts index 39d8e5472..89a75c922 100644 --- a/supabase/functions/ai/features/sparring.ts +++ b/supabase/functions/ai/features/sparring.ts @@ -22,6 +22,8 @@ export interface SparringHandlerOptions { claimToken: string | undefined; /** Called once the scorecard is saved, so the claim is not released. */ onScoreSaved: () => void; + /** Tells the scorer's phones that the round is scored. Must not throw. */ + notifyScored: (userId: string, sessionId: string) => Promise; } // SPARRING: transcript-only opponent replies and scorecards. No research @@ -60,6 +62,7 @@ export async function handleSparring(ctx: FeatureContext, options: SparringHandl parsedOutput.scorecard, ); options.onScoreSaved(); + await options.notifyScored(userId, sparringRequest.sessionId); } const responseOutput = parsedOutput.action === "respond" diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index af3ec5264..40164664e 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -70,6 +70,7 @@ import { getGlobalPrompt, log, narrowSparringDatabase, + sendPushAfterResponse, sparringSupabase, supabase, supabaseServiceKey, @@ -713,6 +714,8 @@ export async function handleRequest(req: Request): Promise { onScoreSaved: () => { sparringScoreClaimed = false; }, + notifyScored: (scorerId, sessionId) => + sendPushAfterResponse([{ kind: "sparring_scored", userId: scorerId, sessionId }]), }); case "crossfire": return await handleCrossfire(ctx, { diff --git a/supabase/functions/ai/runtime.ts b/supabase/functions/ai/runtime.ts index 573051670..4c060929f 100644 --- a/supabase/functions/ai/runtime.ts +++ b/supabase/functions/ai/runtime.ts @@ -10,6 +10,7 @@ import { type SparringDatabaseSchema, } from "./sparring-persistence.ts"; import { loadAuthorizedWorkspaceContext } from "../_shared/workspace-context.ts"; +import { deliverPush, expoAccessToken, type PushNotification, supabasePushStore } from "../_shared/push.ts"; // Generate unique request ID for tracing export function generateRequestId(): string { @@ -36,6 +37,23 @@ export const sparringSupabase = createClient(supabaseUrl export const sparringIntegritySecret = Deno.env.get("SPARRING_INTEGRITY_SECRET")?.trim() || await deriveSparringIntegritySecret(supabaseServiceKey); +const pushStore = supabasePushStore(supabase); + +/** + * Sends push notifications without holding the response: the Edge Runtime + * keeps the worker alive for it through waitUntil. Where there is no Edge + * Runtime (Deno tests), the caller waits instead. Never rejects. + */ +export function sendPushAfterResponse(notifications: PushNotification[]): Promise { + const delivery = deliverPush(pushStore, notifications, { accessToken: expoAccessToken() }); + const runtime = (globalThis as { EdgeRuntime?: { waitUntil?: (promise: Promise) => void } }).EdgeRuntime; + if (typeof runtime?.waitUntil === "function") { + runtime.waitUntil(delivery); + return Promise.resolve(); + } + return delivery; +} + export function narrowSparringDatabase(database: object): SparringDatabase { return database as SparringDatabase; } diff --git a/supabase/functions/ai/voice.test.ts b/supabase/functions/ai/voice.test.ts index 57f424f26..7f164806a 100644 --- a/supabase/functions/ai/voice.test.ts +++ b/supabase/functions/ai/voice.test.ts @@ -32,12 +32,22 @@ function jsonResponse(body: unknown, status = 200): Response { interface Harness { providerBodies: Array<{ messages: Array<{ role: string; content: string }> }>; savedRounds: Array>; + pushTokenQueries: URLSearchParams[]; + pushes: Array>; restore: () => void; } +const PHONE = "ExponentPushToken[scorerphonetoken000000]"; + function install(reply: unknown): Harness { const originalFetch = globalThis.fetch; - const harness: Harness = { providerBodies: [], savedRounds: [], restore: () => { globalThis.fetch = originalFetch; } }; + const harness: Harness = { + providerBodies: [], + savedRounds: [], + pushTokenQueries: [], + pushes: [], + restore: () => { globalThis.fetch = originalFetch; }, + }; globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.origin === "https://supabase.test") { @@ -57,8 +67,20 @@ function install(reply: unknown): Harness { } if (url.pathname.includes("/rest/v1/global_prompts")) return jsonResponse({ prompt_text: null }); if (url.pathname.includes("/rest/v1/ai_requests")) return new Response(null, { status: 201 }); + if (url.pathname.endsWith("/rest/v1/push_tickets")) { + return (init?.method ?? "GET") === "GET" ? jsonResponse([]) : new Response(null, { status: 201 }); + } + if (url.pathname.endsWith("/rest/v1/push_tokens")) { + harness.pushTokenQueries.push(url.searchParams); + return jsonResponse([{ token: PHONE, user_id: USER, locale: "en" }]); + } throw new Error(`Unhandled Supabase request: ${url}`); } + if (url.href === "https://exp.host/--/api/v2/push/send") { + const messages = JSON.parse(String(init?.body)) as Array>; + harness.pushes.push(...messages); + return jsonResponse({ data: messages.map((_, index) => ({ status: "ok", id: `ticket-${index}` })) }); + } if (url.href === "https://openrouter.ai/api/v1/chat/completions") { harness.providerBodies.push(JSON.parse(String(init?.body))); return jsonResponse({ @@ -282,6 +304,35 @@ Deno.test("measured delivery facts reach the judge and the saved round", async ( } }); +Deno.test("a scored round tells only the scorer's phones, without the motion", async () => { + const harness = install(SCORECARD); + try { + const speeches = await completedRound({}); + const response = await handleRequest(aiRequest({ feature: "sparring", input: { ...sparringContext, action: "score", speeches } })); + assert(response.status === 200, `status ${response.status}: ${await response.text()}`); + assert(harness.pushTokenQueries.length === 1, "the scorer's tokens were not read once"); + assert(harness.pushTokenQueries[0].get("user_id") === `in.(${USER})`, "tokens were read for someone other than the scorer"); + assert(harness.pushes.length === 1, `expected one push, got ${harness.pushes.length}`); + const [push] = harness.pushes; + assert(push.to === PHONE, "the push went to the wrong token"); + assert(JSON.stringify(push.data) === JSON.stringify({ kind: "sparring_scored", sessionId: SESSION }), "the push carried more than its kind and round"); + assert(!JSON.stringify(push).includes("public transport"), "the motion reached the lock screen"); + } finally { + harness.restore(); + } +}); + +Deno.test("an opponent reply sends no push", async () => { + const harness = install({ action: "respond", speech: { content: "A plain reply.", strategy: "Hold the line.", targetedPoints: ["cost"] } }); + try { + const response = await handleRequest(aiRequest({ feature: "sparring", input: { ...respondInput, speeches: [userSpeech(0)] } })); + assert(response.status === 200, `status ${response.status}`); + assert(harness.pushTokenQueries.length === 0 && harness.pushes.length === 0, "a reply sent a push"); + } finally { + harness.restore(); + } +}); + Deno.test("a typed round is judged with the typed wording, and malformed or forged measurements are dropped", async () => { for (const extra of [{}, { delivery: { ...DELIVERY, wordsPerMinute: "fast" } }, { delivery: { ...DELIVERY, fillers: [{ word: "", count: 1 }] } }]) { const harness = install(SCORECARD); diff --git a/supabase/migrations/20261014130000_push_tokens.sql b/supabase/migrations/20261014130000_push_tokens.sql new file mode 100644 index 000000000..91a6f0bf9 --- /dev/null +++ b/supabase/migrations/20261014130000_push_tokens.sql @@ -0,0 +1,136 @@ +-- Expo push tokens for the iOS app, and the tickets the Expo push service +-- returns for them. +-- +-- A token belongs to one device, and a device has one signed-in user. When a +-- second account signs in on the same phone, registering moves the token to +-- that account, so the first account's notifications stop reaching a phone it +-- no longer uses. Under RLS the second account cannot see the first one's row, +-- so registration goes through register_push_token, which only ever writes the +-- caller's own user ID. Clients read and delete their own rows directly. +-- +-- Sign-out deletes the account's rows with its own access token. If that +-- fails, the next account on the phone calls release_push_token, which +-- deletes a token whatever account holds it. + +CREATE TABLE public.push_tokens ( + token text PRIMARY KEY + CHECK (token ~ '^Expo(nent)?PushToken\[[A-Za-z0-9_+/=-]{8,200}\]$'), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + platform text NOT NULL CHECK (platform IN ('ios', 'android')), + -- The app's random per-install ID, so a new token from the same install replaces the old one. + installation_id uuid, + -- The app language, so the server writes the notification in it. + locale text NOT NULL DEFAULT 'en' CHECK (locale IN ('en', 'es', 'hu')), + -- Cleared by the sender when Expo reports the device is no longer registered. + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX push_tokens_user_id_idx ON public.push_tokens (user_id); + +ALTER TABLE public.push_tokens ENABLE ROW LEVEL SECURITY; +CREATE POLICY push_tokens_select_own ON public.push_tokens + FOR SELECT TO authenticated + USING (user_id = (SELECT auth.uid())); +CREATE POLICY push_tokens_delete_own ON public.push_tokens + FOR DELETE TO authenticated + USING (user_id = (SELECT auth.uid())); + +REVOKE ALL ON TABLE public.push_tokens FROM PUBLIC, anon, authenticated; +GRANT SELECT, DELETE ON TABLE public.push_tokens TO authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.push_tokens TO service_role; + +-- Tickets wait here until their receipts are read. They carry no user ID: the +-- token says where a message went, and deleting the token deletes its tickets. +CREATE TABLE public.push_tickets ( + id text PRIMARY KEY CHECK (char_length(id) BETWEEN 1 AND 200), + token text NOT NULL REFERENCES public.push_tokens(token) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX push_tickets_token_idx ON public.push_tickets (token); +CREATE INDEX push_tickets_created_at_idx ON public.push_tickets (created_at); + +ALTER TABLE public.push_tickets ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON TABLE public.push_tickets FROM PUBLIC, anon, authenticated; +GRANT SELECT, INSERT, DELETE ON TABLE public.push_tickets TO service_role; + +-- Registers the caller's device token, or refreshes it. Re-registering turns a +-- disabled token back on. An install keeps one token, and an account at most +-- ten, so a client cannot make the sender fan out to many made-up tokens. +CREATE FUNCTION public.register_push_token( + p_token text, + p_platform text, + p_locale text, + p_installation_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid := (SELECT auth.uid()); +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'not signed in' USING ERRCODE = '42501'; + END IF; + + -- One registration per account at a time: two at once would each count + -- only their own new row and could keep eleven between them. + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('push-tokens:' || v_user_id::text, 0)); + + IF p_installation_id IS NOT NULL THEN + DELETE FROM public.push_tokens + WHERE user_id = v_user_id AND installation_id = p_installation_id AND token <> p_token; + END IF; + + INSERT INTO public.push_tokens (token, user_id, platform, installation_id, locale) + VALUES (p_token, v_user_id, p_platform, p_installation_id, COALESCE(p_locale, 'en')) + ON CONFLICT (token) DO UPDATE SET + user_id = EXCLUDED.user_id, + platform = EXCLUDED.platform, + installation_id = EXCLUDED.installation_id, + locale = EXCLUDED.locale, + enabled = true, + updated_at = now(), + last_seen_at = now(); + + DELETE FROM public.push_tokens + WHERE user_id = v_user_id + AND token NOT IN ( + SELECT kept.token FROM public.push_tokens AS kept + WHERE kept.user_id = v_user_id + ORDER BY kept.last_seen_at DESC, kept.token + LIMIT 10 + ); +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.register_push_token(text, text, text, uuid) FROM PUBLIC, anon, service_role; +GRANT EXECUTE ON FUNCTION public.register_push_token(text, text, text, uuid) TO authenticated; + +-- Deletes a token whatever account holds it. Holding the token is the proof: +-- the same caller could already move it to themselves with +-- register_push_token. The app calls it when the account that registered this +-- phone signed out but its rows could not be removed, so that account's +-- notifications stop reaching the next person on the phone. +CREATE FUNCTION public.release_push_token(p_token text) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + IF (SELECT auth.uid()) IS NULL THEN + RAISE EXCEPTION 'not signed in' USING ERRCODE = '42501'; + END IF; + + DELETE FROM public.push_tokens WHERE token = p_token; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.release_push_token(text) FROM PUBLIC, anon, service_role; +GRANT EXECUTE ON FUNCTION public.release_push_token(text) TO authenticated; diff --git a/supabase/migrations/20261014200000_push_registration_withdrawal.sql b/supabase/migrations/20261014200000_push_registration_withdrawal.sql new file mode 100644 index 000000000..4b55fdea6 --- /dev/null +++ b/supabase/migrations/20261014200000_push_registration_withdrawal.sql @@ -0,0 +1,143 @@ +-- A registration the app gave up on can still commit after the phone's rows +-- were released: cancelling an HTTP request does not cancel its database +-- statement. The account that signed out would then keep its notifications on +-- the phone. So every registration now carries the ID of the app's local +-- registration record, and releasing withdraws that ID. Registration refuses +-- a withdrawn ID however late it arrives. Both take the ID's lock and hold it +-- until they commit, so whichever runs second sees the other: a release after +-- a registration deletes its row, and a registration after a release finds +-- its ID withdrawn. + +CREATE TABLE public.push_withdrawn_registrations ( + registration_id uuid PRIMARY KEY, + withdrawn_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX push_withdrawn_registrations_withdrawn_at_idx ON public.push_withdrawn_registrations (withdrawn_at); + +-- Only the two functions below use it. +ALTER TABLE public.push_withdrawn_registrations ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON TABLE public.push_withdrawn_registrations FROM PUBLIC, anon, authenticated, service_role; + +-- The registration that last wrote the row. +ALTER TABLE public.push_tokens ADD COLUMN registration_id uuid; +-- push_tokens comes from the previous migration, which no deployment has run yet, so it is empty. +-- squawk-ignore require-concurrent-index-creation +CREATE INDEX push_tokens_registration_id_idx ON public.push_tokens (registration_id); + +-- No released app calls it, and keeping it would leave a registration that cannot be withdrawn. +-- squawk-ignore ban-drop-function +DROP FUNCTION public.register_push_token(text, text, text, uuid); + +-- As before, plus p_registration, the ID a release withdraws. +CREATE FUNCTION public.register_push_token( + p_token text, + p_platform text, + p_locale text, + p_installation_id uuid, + p_registration uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid := (SELECT auth.uid()); +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'not signed in' USING ERRCODE = '42501'; + END IF; + IF p_registration IS NULL THEN + RAISE EXCEPTION 'registration ID is required' USING ERRCODE = '22004'; + END IF; + + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('push-registration:' || p_registration::text, 0)); + -- A new statement, so under READ COMMITTED it sees a release that committed while this waited. + IF EXISTS (SELECT 1 FROM public.push_withdrawn_registrations WHERE registration_id = p_registration) THEN + RAISE EXCEPTION 'this registration was released' USING ERRCODE = '55000'; + END IF; + + -- One registration per account at a time: two at once would each count + -- only their own new row and could keep eleven between them. + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('push-tokens:' || v_user_id::text, 0)); + + IF p_installation_id IS NOT NULL THEN + DELETE FROM public.push_tokens + WHERE user_id = v_user_id AND installation_id = p_installation_id AND token <> p_token; + END IF; + + INSERT INTO public.push_tokens (token, user_id, platform, installation_id, locale, registration_id) + VALUES (p_token, v_user_id, p_platform, p_installation_id, COALESCE(p_locale, 'en'), p_registration) + ON CONFLICT (token) DO UPDATE SET + user_id = EXCLUDED.user_id, + platform = EXCLUDED.platform, + installation_id = EXCLUDED.installation_id, + locale = EXCLUDED.locale, + registration_id = EXCLUDED.registration_id, + enabled = true, + updated_at = now(), + last_seen_at = now(); + + DELETE FROM public.push_tokens + WHERE user_id = v_user_id + AND token NOT IN ( + SELECT kept.token FROM public.push_tokens AS kept + WHERE kept.user_id = v_user_id + ORDER BY kept.last_seen_at DESC, kept.token + LIMIT 10 + ); +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.register_push_token(text, text, text, uuid, uuid) FROM PUBLIC, anon, service_role; +GRANT EXECUTE ON FUNCTION public.register_push_token(text, text, text, uuid, uuid) TO authenticated; + +-- No released app calls it; the new version also withdraws the registration. +-- squawk-ignore ban-drop-function +DROP FUNCTION public.release_push_token(text); + +-- Withdraws a registration and deletes the phone's rows: the rows that +-- registration wrote, the token whatever account holds it, and the caller's +-- own rows from this install. The app calls it when notifications are turned +-- off, on sign-out with that account's access token, and from the next +-- account when that failed. Knowing the registration ID or the token is the +-- proof: only the phone has them, and the token alone already lets a caller +-- move the row to themselves with register_push_token. +CREATE FUNCTION public.release_push_token( + p_token text, + p_installation_id uuid, + p_registration uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid := (SELECT auth.uid()); +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'not signed in' USING ERRCODE = '42501'; + END IF; + + IF p_registration IS NOT NULL THEN + -- Waits for a registration with this ID that is still running, so the delete below sees its row. + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('push-registration:' || p_registration::text, 0)); + INSERT INTO public.push_withdrawn_registrations (registration_id) VALUES (p_registration) + ON CONFLICT (registration_id) DO NOTHING; + END IF; + + DELETE FROM public.push_tokens + WHERE token = p_token + OR registration_id = p_registration + OR (user_id = v_user_id AND installation_id = p_installation_id); + + -- A request sent before its registration was withdrawn cannot arrive a day + -- later: the access token it carries expires within the hour. + DELETE FROM public.push_withdrawn_registrations WHERE withdrawn_at < now() - interval '1 day'; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.release_push_token(text, uuid, uuid) FROM PUBLIC, anon, service_role; +GRANT EXECUTE ON FUNCTION public.release_push_token(text, uuid, uuid) TO authenticated; diff --git a/supabase/migrations/20261014220000_push_release_by_registration.sql b/supabase/migrations/20261014220000_push_release_by_registration.sql new file mode 100644 index 000000000..9fd61e5e9 --- /dev/null +++ b/supabase/migrations/20261014220000_push_release_by_registration.sql @@ -0,0 +1,57 @@ +-- A release deleted the phone's token whatever registration had written it, +-- and every row the caller's install held. A release that arrived late, after +-- its sign-out had timed out, could then delete a newer registration of the +-- same token: the next account's, or the same account's after it registered +-- again. That account's switch stayed on while its notifications stopped. +-- +-- A release now deletes only the rows its registration wrote, and the caller's +-- own rows from the install that carry no registration ID. A registration ID +-- is used for one registration only: once the app sends a release for it, the +-- next registration gets a new ID. So the rows a release can reach were +-- written before it was sent, and a newer registration keeps its row. The +-- token is no longer an argument. + +-- No released app calls it. +-- squawk-ignore ban-drop-function +DROP FUNCTION public.release_push_token(text, uuid, uuid); + +-- Withdraws a registration and deletes the rows it wrote, whichever account +-- holds them, and the caller's own rows from this install that carry no +-- registration ID. The app calls it when notifications are turned off, on +-- sign-out with that account's access token, and from the next account when +-- that failed. Knowing the registration ID is the proof: only the phone has it. +CREATE FUNCTION public.release_push_token( + p_installation_id uuid, + p_registration uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid := (SELECT auth.uid()); +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'not signed in' USING ERRCODE = '42501'; + END IF; + + IF p_registration IS NOT NULL THEN + -- Waits for a registration with this ID that is still running, so the delete below sees its row. + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('push-registration:' || p_registration::text, 0)); + INSERT INTO public.push_withdrawn_registrations (registration_id) VALUES (p_registration) + ON CONFLICT (registration_id) DO NOTHING; + END IF; + + DELETE FROM public.push_tokens + WHERE registration_id = p_registration + OR (user_id = v_user_id AND installation_id = p_installation_id AND registration_id IS NULL); + + -- A request sent before its registration was withdrawn cannot arrive a day + -- later: the access token it carries expires within the hour. + DELETE FROM public.push_withdrawn_registrations WHERE withdrawn_at < now() - interval '1 day'; +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.release_push_token(uuid, uuid) FROM PUBLIC, anon, service_role; +GRANT EXECUTE ON FUNCTION public.release_push_token(uuid, uuid) TO authenticated; diff --git a/supabase/tests/account_deletion_test.sql b/supabase/tests/account_deletion_test.sql index 09440456d..124b0cc72 100644 --- a/supabase/tests/account_deletion_test.sql +++ b/supabase/tests/account_deletion_test.sql @@ -107,6 +107,8 @@ INSERT INTO public.sparring_sessions (user_id, debate_id, motion, user_side, per VALUES ('ac000000-0000-4000-8000-00000000000a', 'ac100000-0000-4000-8000-00000000000a', 'Motion', 'pro', 'coach'); INSERT INTO public.debate_context_pins (user_id, debate_id, item_kind, item_id) VALUES ('ac000000-0000-4000-8000-00000000000a', 'ac100000-0000-4000-8000-00000000000a', 'chat', 'ac300000-0000-4000-8000-00000000000a'); +INSERT INTO public.push_tokens (token, user_id, platform) + VALUES ('ExponentPushToken[leaverphonetoken00000]', 'ac000000-0000-4000-8000-00000000000a', 'ios'); INSERT INTO public.ai_requests (user_id, feature, provider, model) VALUES ('ac000000-0000-4000-8000-00000000000a', 'brainstorm', 'openrouter', 'model'); INSERT INTO public.daily_usage_counters (user_id, usage_date) VALUES ('ac000000-0000-4000-8000-00000000000a', current_date); diff --git a/supabase/tests/push_tokens_test.sql b/supabase/tests/push_tokens_test.sql new file mode 100644 index 000000000..a862fd3e3 --- /dev/null +++ b/supabase/tests/push_tokens_test.sql @@ -0,0 +1,280 @@ +BEGIN; +SELECT plan(31); + +INSERT INTO auth.users (id) VALUES + ('f1111111-1111-4111-8111-111111111111'), + ('f2222222-2222-4222-8222-222222222222'); + +SELECT ok( + has_table_privilege('authenticated', 'public.push_tokens', 'SELECT') + AND has_table_privilege('authenticated', 'public.push_tokens', 'DELETE') + AND NOT has_table_privilege('authenticated', 'public.push_tokens', 'INSERT') + AND NOT has_table_privilege('authenticated', 'public.push_tokens', 'UPDATE') + AND NOT has_table_privilege('anon', 'public.push_tokens', 'SELECT') + AND NOT has_table_privilege('anon', 'public.push_tokens', 'DELETE') + AND has_table_privilege('service_role', 'public.push_tokens', 'SELECT') + AND has_table_privilege('service_role', 'public.push_tokens', 'UPDATE'), + 'signed-in clients can read and remove tokens but only write them through registration' +); +SELECT ok( + NOT has_table_privilege('authenticated', 'public.push_tickets', 'SELECT') + AND NOT has_table_privilege('authenticated', 'public.push_tickets', 'INSERT') + AND NOT has_table_privilege('authenticated', 'public.push_tickets', 'DELETE') + AND NOT has_table_privilege('anon', 'public.push_tickets', 'SELECT') + AND has_table_privilege('service_role', 'public.push_tickets', 'SELECT') + AND has_table_privilege('service_role', 'public.push_tickets', 'INSERT') + AND has_table_privilege('service_role', 'public.push_tickets', 'DELETE'), + 'push tickets belong to the server only' +); +SELECT ok( + NOT has_table_privilege('authenticated', 'public.push_withdrawn_registrations', 'SELECT') + AND NOT has_table_privilege('authenticated', 'public.push_withdrawn_registrations', 'INSERT') + AND NOT has_table_privilege('authenticated', 'public.push_withdrawn_registrations', 'DELETE') + AND NOT has_table_privilege('anon', 'public.push_withdrawn_registrations', 'SELECT'), + 'clients withdraw registrations only by releasing them' +); +SELECT ok( + has_function_privilege('authenticated', 'public.register_push_token(text, text, text, uuid, uuid)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.register_push_token(text, text, text, uuid, uuid)', 'EXECUTE') + AND NOT has_function_privilege('service_role', 'public.register_push_token(text, text, text, uuid, uuid)', 'EXECUTE'), + 'only a signed-in user can register a token, and only for themselves' +); +SELECT ok( + has_function_privilege('authenticated', 'public.release_push_token(uuid, uuid)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.release_push_token(uuid, uuid)', 'EXECUTE') + AND NOT has_function_privilege('service_role', 'public.release_push_token(uuid, uuid)', 'EXECUTE'), + 'only a signed-in user can release a token' +); + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'f1111111-1111-4111-8111-111111111111', true); + +SELECT lives_ok( + $$SELECT public.register_push_token('ExponentPushToken[aaaaaaaaaaaaaaaaaaaaaa]', 'ios', 'hu', 'fa000000-0000-4000-8000-000000000001', 'ab000000-0000-4000-8000-000000000001')$$, + 'a user registers their phone' +); +SELECT results_eq( + $$SELECT user_id, locale, enabled FROM public.push_tokens$$, + $$VALUES ('f1111111-1111-4111-8111-111111111111'::uuid, 'hu'::text, true)$$, + 'the token is stored for the caller with their language' +); +SELECT throws_ok( + $$INSERT INTO public.push_tokens (token, user_id, platform) + VALUES ('ExponentPushToken[bbbbbbbbbbbbbbbbbbbbbb]', 'f2222222-2222-4222-8222-222222222222', 'ios')$$, + '42501', + NULL, + 'a client cannot write a token row directly, so it cannot file one under another user' +); +SELECT throws_ok( + $$SELECT public.register_push_token('not a token', 'ios', 'en', NULL, 'ab000000-0000-4000-8000-000000000009')$$, + '23514', + NULL, + 'only Expo push tokens are accepted' +); +SELECT throws_ok( + $$SELECT public.register_push_token('ExponentPushToken[cccccccccccccccccccccc]', 'ios', 'en', NULL, NULL)$$, + '22004', + NULL, + 'every registration carries the ID a release can withdraw' +); +SELECT throws_ok( + $$SELECT public.register_push_token('ExponentPushToken[cccccccccccccccccccccc]', 'ios', 'fr', NULL, 'ab000000-0000-4000-8000-000000000009')$$, + '23514', + NULL, + 'only the app languages are accepted' +); + +SELECT set_config('request.jwt.claim.sub', 'f2222222-2222-4222-8222-222222222222', true); +SELECT is( + (SELECT count(*) FROM public.push_tokens)::integer, + 0, + 'another user sees none of the tokens' +); +SELECT is_empty( + $$DELETE FROM public.push_tokens RETURNING token$$, + 'another user cannot remove the tokens' +); + +-- The second account signs in on the same phone. +SELECT lives_ok( + $$SELECT public.register_push_token('ExponentPushToken[aaaaaaaaaaaaaaaaaaaaaa]', 'ios', 'en', 'fa000000-0000-4000-8000-000000000001', 'ab000000-0000-4000-8000-000000000002')$$, + 'the second account registers the same phone' +); +SELECT results_eq( + $$SELECT token, user_id, locale FROM public.push_tokens$$, + $$VALUES ('ExponentPushToken[aaaaaaaaaaaaaaaaaaaaaa]'::text, 'f2222222-2222-4222-8222-222222222222'::uuid, 'en'::text)$$, + 'the token moved to the second account with its language' +); + +SELECT set_config('request.jwt.claim.sub', 'f1111111-1111-4111-8111-111111111111', true); +SELECT is( + (SELECT count(*) FROM public.push_tokens)::integer, + 0, + 'the first account no longer holds the phone''s token' +); + +RESET ROLE; +UPDATE public.push_tokens SET enabled = false, last_seen_at = now() - interval '1 day'; +INSERT INTO public.push_tickets (id, token) VALUES ('ticket-1', 'ExponentPushToken[aaaaaaaaaaaaaaaaaaaaaa]'); + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'f2222222-2222-4222-8222-222222222222', true); +SELECT public.register_push_token('ExponentPushToken[aaaaaaaaaaaaaaaaaaaaaa]', 'ios', 'en', 'fa000000-0000-4000-8000-000000000001', 'ab000000-0000-4000-8000-000000000002'); +SELECT ok( + (SELECT enabled AND last_seen_at > now() - interval '1 minute' FROM public.push_tokens), + 'registering again turns a disabled token back on' +); + +-- The install's token changed, so the old one must stop receiving. +SELECT public.register_push_token('ExponentPushToken[dddddddddddddddddddddd]', 'ios', 'en', 'fa000000-0000-4000-8000-000000000001', 'ab000000-0000-4000-8000-000000000002'); +SELECT results_eq( + $$SELECT token FROM public.push_tokens$$, + $$VALUES ('ExponentPushToken[dddddddddddddddddddddd]'::text)$$, + 'a new token from the same install replaces the old one' +); + +RESET ROLE; +SELECT is( + (SELECT count(*) FROM public.push_tickets)::integer, + 0, + 'a replaced token takes its tickets with it' +); + +INSERT INTO public.push_tokens (token, user_id, platform, last_seen_at) +SELECT format('ExponentPushToken[old%s]', lpad(n::text, 17, '0')), 'f2222222-2222-4222-8222-222222222222', 'ios', now() - n * interval '1 hour' +FROM generate_series(1, 12) AS n; + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'f2222222-2222-4222-8222-222222222222', true); +SELECT public.register_push_token('ExponentPushToken[eeeeeeeeeeeeeeeeeeeeee]', 'ios', 'en', 'fa000000-0000-4000-8000-000000000002', 'ab000000-0000-4000-8000-000000000003'); +SELECT ok( + (SELECT count(*) = 10 AND bool_or(token = 'ExponentPushToken[eeeeeeeeeeeeeeeeeeeeee]') FROM public.push_tokens), + 'an account keeps its ten most recently seen tokens' +); + +-- READ COMMITTED would let two registrations at once each count only their +-- own row, so registration holds a per-account lock until it commits. +RESET ROLE; +SELECT ok( + EXISTS ( + SELECT 1 FROM pg_catalog.pg_locks AS held + CROSS JOIN LATERAL (SELECT hashtextextended('push-tokens:f2222222-2222-4222-8222-222222222222', 0) AS key) AS lock + WHERE held.locktype = 'advisory' + AND held.pid = pg_backend_pid() + AND held.mode = 'ExclusiveLock' + AND held.objsubid = 1 + AND held.classid = ((lock.key >> 32) & 4294967295)::text::oid + AND held.objid = (lock.key & 4294967295)::text::oid + ), + 'registration holds the account''s lock until the transaction ends' +); +SELECT ok( + EXISTS ( + SELECT 1 FROM pg_catalog.pg_locks AS held + CROSS JOIN LATERAL (SELECT hashtextextended('push-registration:ab000000-0000-4000-8000-000000000003', 0) AS key) AS lock + WHERE held.locktype = 'advisory' + AND held.pid = pg_backend_pid() + AND held.mode = 'ExclusiveLock' + AND held.objsubid = 1 + AND held.classid = ((lock.key >> 32) & 4294967295)::text::oid + AND held.objid = (lock.key & 4294967295)::text::oid + ), + 'registration holds its ID''s lock until the transaction ends, so a release of it waits for the row' +); +SET LOCAL ROLE authenticated; + +-- A release with no registration ID reaches only the caller's own rows from +-- the install that no registration wrote. +RESET ROLE; +INSERT INTO public.push_tokens (token, user_id, platform, installation_id) +VALUES ('ExponentPushToken[jjjjjjjjjjjjjjjjjjjjjj]', 'f1111111-1111-4111-8111-111111111111', 'ios', 'fa000000-0000-4000-8000-000000000002'); +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'f1111111-1111-4111-8111-111111111111', true); +SELECT public.release_push_token('fa000000-0000-4000-8000-000000000002', NULL); +RESET ROLE; +SELECT ok( + (SELECT count(*) = 10 AND bool_or(token = 'ExponentPushToken[eeeeeeeeeeeeeeeeeeeeee]') AND NOT bool_or(token = 'ExponentPushToken[jjjjjjjjjjjjjjjjjjjjjj]') FROM public.push_tokens), + 'a release without a registration ID deletes the caller''s unregistered rows from the install, not another account''s' +); +SET LOCAL ROLE authenticated; + +-- The app gave up on a registration that is still running, and the phone's +-- registration was released: it must not land afterwards. +SELECT set_config('request.jwt.claim.sub', 'f1111111-1111-4111-8111-111111111111', true); +SELECT public.register_push_token('ExponentPushToken[gggggggggggggggggggggg]', 'ios', 'en', 'fa000000-0000-4000-8000-000000000003', 'ab000000-0000-4000-8000-000000000004'); +SELECT set_config('request.jwt.claim.sub', 'f2222222-2222-4222-8222-222222222222', true); +SELECT public.release_push_token('fa000000-0000-4000-8000-000000000003', 'ab000000-0000-4000-8000-000000000004'); +RESET ROLE; +SELECT ok( + (SELECT count(*) = 10 AND NOT bool_or(token = 'ExponentPushToken[gggggggggggggggggggggg]') FROM public.push_tokens), + 'releasing a registration deletes the row it wrote, whichever account holds it, and nothing else' +); +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'f1111111-1111-4111-8111-111111111111', true); +SELECT throws_ok( + $$SELECT public.register_push_token('ExponentPushToken[gggggggggggggggggggggg]', 'ios', 'en', 'fa000000-0000-4000-8000-000000000003', 'ab000000-0000-4000-8000-000000000004')$$, + '55000', + NULL, + 'a released registration that arrives late is refused' +); +SELECT lives_ok( + $$SELECT public.register_push_token('ExponentPushToken[gggggggggggggggggggggg]', 'ios', 'en', 'fa000000-0000-4000-8000-000000000003', 'ab000000-0000-4000-8000-000000000005')$$, + 'the phone can register again with a new registration ID' +); + +-- The first account's release on sign-out timed out but is still on its way. +-- The next account releases that registration itself and turns notifications +-- on with the same token; the first account's release arrives last. +SELECT public.register_push_token('ExponentPushToken[hhhhhhhhhhhhhhhhhhhhhh]', 'ios', 'en', 'fa000000-0000-4000-8000-000000000004', 'ab000000-0000-4000-8000-000000000006'); +SELECT set_config('request.jwt.claim.sub', 'f2222222-2222-4222-8222-222222222222', true); +SELECT public.release_push_token('fa000000-0000-4000-8000-000000000004', 'ab000000-0000-4000-8000-000000000006'); +SELECT public.register_push_token('ExponentPushToken[hhhhhhhhhhhhhhhhhhhhhh]', 'ios', 'en', 'fa000000-0000-4000-8000-000000000004', 'ab000000-0000-4000-8000-000000000007'); +SELECT set_config('request.jwt.claim.sub', 'f1111111-1111-4111-8111-111111111111', true); +SELECT public.release_push_token('fa000000-0000-4000-8000-000000000004', 'ab000000-0000-4000-8000-000000000006'); +RESET ROLE; +SELECT results_eq( + $$SELECT user_id, registration_id, enabled FROM public.push_tokens WHERE token = 'ExponentPushToken[hhhhhhhhhhhhhhhhhhhhhh]'$$, + $$VALUES ('f2222222-2222-4222-8222-222222222222'::uuid, 'ab000000-0000-4000-8000-000000000007'::uuid, true)$$, + 'a release that arrives late leaves the next account''s newer registration of the same token alone' +); +SET LOCAL ROLE authenticated; + +-- The same account: its release of one registration arrives after it +-- registered the same install again under a new ID. +SELECT set_config('request.jwt.claim.sub', 'f2222222-2222-4222-8222-222222222222', true); +SELECT public.release_push_token('fa000000-0000-4000-8000-000000000004', 'ab000000-0000-4000-8000-000000000007'); +SELECT public.register_push_token('ExponentPushToken[hhhhhhhhhhhhhhhhhhhhhh]', 'ios', 'en', 'fa000000-0000-4000-8000-000000000004', 'ab000000-0000-4000-8000-000000000008'); +SELECT public.release_push_token('fa000000-0000-4000-8000-000000000004', 'ab000000-0000-4000-8000-000000000007'); +RESET ROLE; +SELECT results_eq( + $$SELECT user_id, registration_id FROM public.push_tokens WHERE token = 'ExponentPushToken[hhhhhhhhhhhhhhhhhhhhhh]'$$, + $$VALUES ('f2222222-2222-4222-8222-222222222222'::uuid, 'ab000000-0000-4000-8000-000000000008'::uuid)$$, + 'a late release leaves the same account''s newer registration from the same install alone' +); +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'f1111111-1111-4111-8111-111111111111', true); + +SELECT set_config('request.jwt.claim.sub', '', true); +SELECT throws_ok( + $$SELECT public.register_push_token('ExponentPushToken[ffffffffffffffffffffff]', 'ios', 'en', NULL, 'ab000000-0000-4000-8000-000000000009')$$, + '42501', + NULL, + 'a token cannot be registered without a signed-in user' +); +SELECT throws_ok( + $$SELECT public.release_push_token('fa000000-0000-4000-8000-000000000001', NULL)$$, + '42501', + NULL, + 'nothing can be released without a signed-in user' +); + +RESET ROLE; +DELETE FROM auth.users WHERE id = 'f2222222-2222-4222-8222-222222222222'; +SELECT results_eq( + $$SELECT token FROM public.push_tokens$$, + $$VALUES ('ExponentPushToken[gggggggggggggggggggggg]'::text)$$, + 'deleting an account removes its tokens and leaves other accounts'' tokens' +); + +SELECT * FROM finish(); +ROLLBACK;