From a9da96cc802b9d7cef81642e783eed4814ffd57d Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:25:21 +0000 Subject: [PATCH 01/12] Store Expo push tokens per user with owner-only access register_push_token writes only the caller's user ID and moves a token when a second account signs in on the same phone. Clients read and delete their own rows; tickets are server-only and keyed on the token. Co-Authored-By: Claude Opus 5.5 --- .../migrations/20261014120000_push_tokens.sql | 105 ++++++++++++ supabase/tests/account_deletion_test.sql | 2 + supabase/tests/push_tokens_test.sql | 153 ++++++++++++++++++ 3 files changed, 260 insertions(+) create mode 100644 supabase/migrations/20261014120000_push_tokens.sql create mode 100644 supabase/tests/push_tokens_test.sql diff --git a/supabase/migrations/20261014120000_push_tokens.sql b/supabase/migrations/20261014120000_push_tokens.sql new file mode 100644 index 000000000..caa0f4066 --- /dev/null +++ b/supabase/migrations/20261014120000_push_tokens.sql @@ -0,0 +1,105 @@ +-- 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. + +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; + + 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; diff --git a/supabase/tests/account_deletion_test.sql b/supabase/tests/account_deletion_test.sql index d68c77fc2..880f39cd9 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..9cee685dc --- /dev/null +++ b/supabase/tests/push_tokens_test.sql @@ -0,0 +1,153 @@ +BEGIN; +SELECT plan(19); + +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( + has_function_privilege('authenticated', 'public.register_push_token(text, text, text, uuid)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.register_push_token(text, text, text, uuid)', 'EXECUTE') + AND NOT has_function_privilege('service_role', 'public.register_push_token(text, text, text, uuid)', 'EXECUTE'), + 'only a signed-in user can register a token, and only for themselves' +); + +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')$$, + '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)$$, + '23514', + NULL, + 'only Expo push tokens are accepted' +); +SELECT throws_ok( + $$SELECT public.register_push_token('ExponentPushToken[cccccccccccccccccccccc]', 'ios', 'fr', NULL)$$, + '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')$$, + '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'); +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'); +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'); +SELECT ok( + (SELECT count(*) = 10 AND bool_or(token = 'ExponentPushToken[eeeeeeeeeeeeeeeeeeeeee]') FROM public.push_tokens), + 'an account keeps its ten most recently seen tokens' +); + +SELECT set_config('request.jwt.claim.sub', '', true); +SELECT throws_ok( + $$SELECT public.register_push_token('ExponentPushToken[ffffffffffffffffffffff]', 'ios', 'en', NULL)$$, + '42501', + NULL, + 'a token cannot be registered without a signed-in user' +); + +RESET ROLE; +DELETE FROM auth.users WHERE id = 'f2222222-2222-4222-8222-222222222222'; +SELECT is( + (SELECT count(*) FROM public.push_tokens)::integer, + 0, + 'deleting an account removes its tokens' +); + +SELECT * FROM finish(); +ROLLBACK; From a71a7ad0111bdfd107768fd6b2be27431637b358 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:26:12 +0000 Subject: [PATCH 02/12] Add an Expo push sender with receipts and per-user message building Messages are built from the notification of the user who owns each token row, sent in batches of 100, and DeviceNotRegistered tickets or receipts turn the token off. EXPO_ACCESS_TOKEN is optional. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- supabase/functions/_shared/push.test.ts | 235 ++++++++++++++++++++ supabase/functions/_shared/push.ts | 284 ++++++++++++++++++++++++ 3 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 supabase/functions/_shared/push.test.ts create mode 100644 supabase/functions/_shared/push.ts diff --git a/package.json b/package.json index 7a863ac9b..672b5ed43 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 --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,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/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; +} From 75b04c7b3b1a3d2b7516af49ee92f3cda29555ad Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:36:43 +0000 Subject: [PATCH 03/12] Tell the scorer's phones when a sparring round is scored The ai function sends the push after the scorecard is saved, through EdgeRuntime.waitUntil so the response does not wait for Expo. The message names only the round, never the motion, and goes only to the scorer's tokens. Co-Authored-By: Claude Opus 5.5 --- supabase/functions/ai/features/sparring.ts | 3 ++ supabase/functions/ai/index.ts | 3 ++ supabase/functions/ai/runtime.ts | 18 ++++++++ supabase/functions/ai/voice.test.ts | 53 +++++++++++++++++++++- 4 files changed, 76 insertions(+), 1 deletion(-) 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 af631f2db..527080b32 100644 --- a/supabase/functions/ai/voice.test.ts +++ b/supabase/functions/ai/voice.test.ts @@ -31,12 +31,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); From de309242626b8cf73bb13fbe13d9e89adc0ade0a Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:46:35 +0000 Subject: [PATCH 04/12] Let a phone opt in to notifications from the You tab A Notifications switch asks for permission and files the phone's Expo push token under the signed-in user through register_push_token. The app registers again on launch and when the language changes, and removes the token on sign-out while the session still works. Changes run one at a time, so a sign-out never races a registration in flight. Builds without an EAS project ID cannot get a token, so until app.json has one the switch shows as unavailable. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/package.json | 1 + apps/mobile/src/app/(tabs)/(you)/you.tsx | 89 ++++++++++++- apps/mobile/src/app/_layout.tsx | 12 +- apps/mobile/src/lib/auth.tsx | 3 + apps/mobile/src/lib/push.ts | 159 +++++++++++++++++++++++ apps/mobile/src/lib/push.web.ts | 35 +++++ package-lock.json | 34 +++++ src/locales/en.json | 10 +- src/locales/es.json | 10 +- src/locales/hu.json | 10 +- 10 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/lib/push.ts create mode 100644 apps/mobile/src/lib/push.web.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index db9201929..7ef444b3a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -28,6 +28,7 @@ "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~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/src/app/(tabs)/(you)/you.tsx b/apps/mobile/src/app/(tabs)/(you)/you.tsx index 27d4238c4..d985a00e2 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, useRef, 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,7 @@ 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 { useSettings, type ThemePreference } from '@/lib/settings'; import { fetchSubscription, type Subscription, type SubscriptionTier } from '@/lib/subscription'; import { supabaseEnv } from '@/lib/supabase-config'; @@ -89,6 +91,8 @@ export default function YouScreen() { ) : null} + + {t('settings.account')} @@ -143,6 +147,86 @@ 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, setState] = useState(null); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + // Bumped by every read and change, so an older answer never overwrites a newer one. + const request = useRef(0); + + const load = useCallback(() => { + if (!userId) return; + const id = ++request.current; + readPushState(userId).then( + (next) => { + if (id === request.current) setState(next); + }, + (cause: unknown) => console.warn('Could not read the notification state:', cause instanceof Error ? cause.message : cause), + ); + }, [userId]); + + // 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; + const id = ++request.current; + setBusy(true); + setFailed(false); + (on ? enablePush(userId, language) : disablePush(userId).then((): PushState => 'off')).then( + (next) => { + if (id === request.current) setState(next); + }, + (cause: unknown) => { + console.warn('Could not change notifications:', cause instanceof Error ? cause.message : cause); + if (id === request.current) setFailed(true); + }, + ).finally(() => { + if (id === request.current) setBusy(false); + }); + }; + + 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(); @@ -244,4 +328,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 af126945e..fea929dc0 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -11,6 +11,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { AuthProvider, useAuth } from '@/lib/auth'; import { DebateProvider } from '@/lib/debate-context'; +import { refreshPush } from '@/lib/push'; import { SettingsProvider, useSettings } from '@/lib/settings'; import { interFonts } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; @@ -32,7 +33,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; @@ -41,6 +42,15 @@ function Root() { if (ready) void SplashScreen.hideAsync(); }, [ready]); + // 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 85ab720d0..7911c063f 100644 --- a/apps/mobile/src/lib/auth.tsx +++ b/apps/mobile/src/lib/auth.tsx @@ -2,6 +2,7 @@ import type { Session } from '@supabase/supabase-js'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { getInstallId, sessionStorage } from './device-storage'; +import { forgetPushOnSignOut } from './push'; import { setSessionOwner } from './session-memory'; import { supabase } from './supabase'; import { sessionStorageKey } from './supabase-config'; @@ -143,6 +144,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); // 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.ts b/apps/mobile/src/lib/push.ts new file mode 100644 index 000000000..778c7506d --- /dev/null +++ b/apps/mobile/src/lib/push.ts @@ -0,0 +1,159 @@ +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 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. + * + * - 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.json), 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, and for whom. Only that user's session +// may remove the row, so a registration left by another account is dropped. +const REGISTRATION_KEY = 'debatelab.push-registration'; +const SIGN_OUT_BUDGET_MS = 5_000; + +type Registration = { userId: string; token: string }; + +// One change at a time, so sign-out's removal 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 } = value as Record; + return typeof userId === 'string' && typeof token === 'string' ? { userId, token } : null; + } catch { + return null; + } +} + +async function register(userId: string, language: AppLanguage): Promise { + const token = (await Notifications.getExpoPushTokenAsync({ projectId: projectId() })).data; + // register_push_token files the token under whoever is signed in now; make sure that is still this user. + const { data } = await supabase.auth.getSession(); + if (data.session?.user.id !== userId) throw new Error('The account changed before the phone was registered.'); + const { error } = await supabase.rpc('register_push_token', { + p_token: token, + p_platform: Platform.OS, + p_locale: language, + p_installation_id: await getInstallId(), + }); + if (error) throw new Error(error.message); + await AsyncStorage.setItem(REGISTRATION_KEY, JSON.stringify({ userId, token } satisfies Registration)); +} + +/** Removes this phone's token row. RLS lets a user delete only their own rows. */ +async function removeToken(registration: Registration, signal?: AbortSignal): Promise { + const query = supabase.from('push_tokens').delete().eq('token', registration.token).eq('user_id', registration.userId); + const { error } = await (signal ? query.abortSignal(signal) : query); + if (error) throw new Error(error.message); +} + +export async function readPushState(userId: string): Promise { + if (!available()) return 'unavailable'; + const [permissions, registration] = await Promise.all([Notifications.getPermissionsAsync(), readRegistration()]); + if (registration?.userId === userId) 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'; + let permissions = await Notifications.getPermissionsAsync(); + if (!allowed(permissions) && permissions.canAskAgain) permissions = await Notifications.requestPermissionsAsync(); + if (!allowed(permissions)) return 'blocked'; + await register(userId, language); + return 'on'; + }); +} + +/** Stops this user's notifications on this phone. Throws if the row could not be removed, so the switch stays on. */ +export function disablePush(userId: string): Promise { + return serial(async () => { + const registration = await readRegistration(); + if (registration?.userId === userId) await removeToken(registration); + await AsyncStorage.removeItem(REGISTRATION_KEY); + }); +} + +/** + * 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. A + * registration left by another account (its session ended without signing + * out here) is forgotten; the next opt-in moves the token. + */ +export function refreshPush(userId: string, language: AppLanguage): Promise { + return serial(async () => { + const registration = await readRegistration(); + if (!registration) return; + if (registration.userId !== userId) { + await AsyncStorage.removeItem(REGISTRATION_KEY); + return; + } + if (!available() || !allowed(await Notifications.getPermissionsAsync())) return; + await register(userId, language); + }); +} + +/** + * Called by sign-out while the session still works, so the next person on + * this phone never gets this account's notifications. Never throws, and gives + * up after five seconds so an offline sign-out still signs out. + */ +export async function forgetPushOnSignOut(userId: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SIGN_OUT_BUDGET_MS); + const forget = serial(async () => { + try { + const registration = await readRegistration(); + if (registration?.userId === userId) await removeToken(registration, controller.signal); + } finally { + await AsyncStorage.removeItem(REGISTRATION_KEY); + } + }); + try { + await untilAborted(forget, controller.signal); + } catch (cause) { + console.warn('Could not remove the push token on sign-out:', cause instanceof Error ? cause.message : cause); + } 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..cf3d0b8ae --- /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): Promise { + enabledFor = null; + return Promise.resolve(); +} diff --git a/package-lock.json b/package-lock.json index b7a6f6c45..1896148c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -137,6 +137,7 @@ "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", + "expo-notifications": "~57.0.22", "expo-print": "~57.0.2", "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", @@ -542,6 +543,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", @@ -724,6 +734,24 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-notifications": { + "version": "57.0.22", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.22.tgz", + "integrity": "sha512-JVZCyY660d7L5bKcNK8J3/ghG70WtyRYlvAQgAISlgRCt3QWhghhodx/j+nRAPcfc2mmCzUjNB0Ck+LbzpBEMg==", + "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.21" + }, + "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", @@ -9981,6 +10009,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/src/locales/en.json b/src/locales/en.json index b995e085e..348dba660 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2442,7 +2442,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 99eb6035b..5e98f6584 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2442,7 +2442,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 aa83b303b..68677add7 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2442,7 +2442,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." From bbb8e0949d5aeeaa4f5c390bbc331e8051cfa55d Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:46:35 +0000 Subject: [PATCH 05/12] Let the screenshot script shoot only some signed-in screens SCREENS=you,timer limits a run to those groups, so a change to one screen does not reshoot the whole app. The You group also shoots the Notifications switch turned on. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 97 ++++++++++++++++++++---------- 1 file changed, 66 insertions(+), 31 deletions(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index d4c27ca49..7196faf31 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, flowchart, one-off. Sign-in screens are always shot. * * Signed-in screens need a staging session. With STAGING_DEMO_CODE set, the * script signs in once through the real sign-in screen and also checks that a @@ -68,6 +71,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`; @@ -79,6 +84,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 = { @@ -1019,49 +1025,78 @@ 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 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('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(); } } From 11fd7a68c386cb09da07f8fbb880daa64aade8e2 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:47:34 +0000 Subject: [PATCH 06/12] Document push tokens, the Expo sender and what is not wired yet supabase/README.md covers register_push_token and the RLS around it, the sender's rules, the one wired trigger, where trial_ending and announcements would hook in, and the deploy order. EXPO_ACCESS_TOKEN is optional and unset. The app guide says no build gets a token until app.json has an EAS project ID, and that prebuild adds the aps-environment entitlement. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 3 +++ supabase/README.md | 25 +++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 5c2f84e46..2a77a2b2a 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -28,6 +28,9 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines) and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. Voice rounds need the development build, so the voice switch shows disabled. The website cannot reopen a saved round, so neither can the app. - 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 and Crossfire 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. +- 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 removes the row while the session still works. 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.json` has `extra.eas.projectId`, which needs an Expo account and `eas init` (Adam's step). 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.json` does not list it, and the plugin sets the `aps-environment` entitlement. A development build signed with a profile that lacks the push entitlement (the borrowed Signulous one) has to account for that. - `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/supabase/README.md b/supabase/README.md index bac85dcb0..78e4b3bd2 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. @@ -75,10 +75,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 (`20261014120000_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, and turns a disabled token back on. 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: @@ -124,6 +142,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. +`20261014120000_push_tokens.sql` is additive: it creates `push_tokens`, `push_tickets` and `register_push_token`. See [Push notifications](#push-notifications). + `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. `20260926090100_legacy_debate_cleanup.sql` defines `private.purge_legacy_debate_material`, which deletes legacy results, chats, flowcharts, sparring rounds, and sources that belong to no debate and had no activity in the 30 days before `20260926090000_current_debates.sql` recorded its rollout time in `private.current_debate_rollout`. The migration does not call it, so applying it deletes nothing; legacy rows stay in the Unsorted view. Running the purge on a remote project is a separate, destructive step that needs explicit authorization for that environment. Work created after the rollout is never touched, however late it runs. @@ -164,6 +184,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: From 204eca654be2c6511d4afcf1fb21d18cfbbc49f8 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:52:23 +0000 Subject: [PATCH 07/12] Say why a screenshot run's demo-code sign-in failed The run now prints the sign-in screen's alert and shoots the screen with the code field emptied, instead of timing out on the missing tab bar. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 7196faf31..93a81fb59 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -931,7 +931,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}`); } From c006516828c95d5e140e72593411d6ef53407e6e Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:56:11 +0000 Subject: [PATCH 08/12] Serialize push token registration per account Two register_push_token calls at once each saw only their own new row under READ COMMITTED, so together they could keep eleven tokens. The function now takes a per-account advisory lock until it commits. Co-Authored-By: Claude Opus 5.5 --- .../migrations/20261014120000_push_tokens.sql | 4 ++++ supabase/tests/push_tokens_test.sql | 20 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/supabase/migrations/20261014120000_push_tokens.sql b/supabase/migrations/20261014120000_push_tokens.sql index caa0f4066..2eed82454 100644 --- a/supabase/migrations/20261014120000_push_tokens.sql +++ b/supabase/migrations/20261014120000_push_tokens.sql @@ -74,6 +74,10 @@ BEGIN 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; diff --git a/supabase/tests/push_tokens_test.sql b/supabase/tests/push_tokens_test.sql index 9cee685dc..aadde0761 100644 --- a/supabase/tests/push_tokens_test.sql +++ b/supabase/tests/push_tokens_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(19); +SELECT plan(20); INSERT INTO auth.users (id) VALUES ('f1111111-1111-4111-8111-111111111111'), @@ -133,6 +133,24 @@ SELECT ok( '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' +); +SET LOCAL ROLE authenticated; + SELECT set_config('request.jwt.claim.sub', '', true); SELECT throws_ok( $$SELECT public.register_push_token('ExponentPushToken[ffffffffffffffffffffff]', 'ios', 'en', NULL)$$, From 4d87f700cf9ee8d22bd5dc52b235c34b01b2bec8 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:57:33 +0000 Subject: [PATCH 09/12] Keep the notification switch usable when the app is reopened mid-change A read and a change shared one request counter, so foregrounding the app during a change bumped it and the change never cleared busy or showed its failure. Only the newest change now owns busy and failure, the newest read or change still owns the state, and reads queue behind a change in flight so they report where it ended. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/(tabs)/(you)/you.tsx | 36 ++++-------------- apps/mobile/src/lib/push-switch.test.ts | 47 ++++++++++++++++++++++++ apps/mobile/src/lib/push-switch.ts | 43 ++++++++++++++++++++++ apps/mobile/src/lib/push.ts | 13 ++++--- 4 files changed, 105 insertions(+), 34 deletions(-) create mode 100644 apps/mobile/src/lib/push-switch.test.ts create mode 100644 apps/mobile/src/lib/push-switch.ts diff --git a/apps/mobile/src/app/(tabs)/(you)/you.tsx b/apps/mobile/src/app/(tabs)/(you)/you.tsx index d985a00e2..0eb86c0a9 100644 --- a/apps/mobile/src/app/(tabs)/(you)/you.tsx +++ b/apps/mobile/src/app/(tabs)/(you)/you.tsx @@ -1,6 +1,6 @@ import { APP_LANGUAGES, type AppLanguage } from '@debatelab/core/languages'; import { useFocusEffect, useRouter } from 'expo-router'; -import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, AppState, Linking, Pressable, StyleSheet, View } from 'react-native'; @@ -15,6 +15,7 @@ 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'; @@ -159,22 +160,12 @@ function NotificationsCard({ userId }: { userId: string | undefined }) { const { t } = useTranslation(); const { colors } = useTheme(); const { language } = useSettings(); - const [state, setState] = useState(null); - const [busy, setBusy] = useState(false); - const [failed, setFailed] = useState(false); - // Bumped by every read and change, so an older answer never overwrites a newer one. - const request = useRef(0); + 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) return; - const id = ++request.current; - readPushState(userId).then( - (next) => { - if (id === request.current) setState(next); - }, - (cause: unknown) => console.warn('Could not read the notification state:', cause instanceof Error ? cause.message : cause), - ); - }, [userId]); + if (userId) pushSwitch.load(() => readPushState(userId)); + }, [userId, pushSwitch]); // The switch may have changed in iOS Settings while DebateLab was in the background. useFocusEffect(load); @@ -187,20 +178,7 @@ function NotificationsCard({ userId }: { userId: string | undefined }) { const change = (on: boolean) => { if (!userId) return; - const id = ++request.current; - setBusy(true); - setFailed(false); - (on ? enablePush(userId, language) : disablePush(userId).then((): PushState => 'off')).then( - (next) => { - if (id === request.current) setState(next); - }, - (cause: unknown) => { - console.warn('Could not change notifications:', cause instanceof Error ? cause.message : cause); - if (id === request.current) setFailed(true); - }, - ).finally(() => { - if (id === request.current) setBusy(false); - }); + pushSwitch.change(() => (on ? enablePush(userId, language) : disablePush(userId).then((): PushState => 'off'))); }; return ( 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.ts b/apps/mobile/src/lib/push.ts index 778c7506d..902752cd1 100644 --- a/apps/mobile/src/lib/push.ts +++ b/apps/mobile/src/lib/push.ts @@ -86,11 +86,14 @@ async function removeToken(registration: Registration, signal?: AbortSignal): Pr if (error) throw new Error(error.message); } -export async function readPushState(userId: string): Promise { - if (!available()) return 'unavailable'; - const [permissions, registration] = await Promise.all([Notifications.getPermissionsAsync(), readRegistration()]); - if (registration?.userId === userId) return allowed(permissions) ? 'on' : 'blocked'; - return !allowed(permissions) && !permissions.canAskAgain ? 'blocked' : 'off'; +/** 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) return allowed(permissions) ? 'on' : 'blocked'; + return !allowed(permissions) && !permissions.canAskAgain ? 'blocked' : 'off'; + }); } /** Asks for permission if needed, then files the token under this user. */ From a178cf765275c1f61b246c72cb21da51b97632a5 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 14:01:44 +0000 Subject: [PATCH 10/12] Remove a signed-out account's push token even when its registration lands late If a registration was still out when sign-out's five seconds ran out, the queued removal ran later with the already-aborted signal, failed, and then cleared the local record anyway, so the account's token stayed enabled for the next person on the phone. - The removal carries the account's own access token, which keeps working after sign-out until it expires, and has its own deadline instead of sign-out's wait. It deletes every row this install filed for the account. - The local record is cleared only after the server delete succeeds. If it never does, the next account on the phone releases the token through the new release_push_token RPC before doing anything else with push. - A first registration is recorded before the request, so one whose answer was lost is still removed on sign-out (or by the next launch). - Every queued round of push requests has a 20 second deadline, so a hung token fetch or request cannot hold the queue, and registration sends the requesting account's token so it cannot be filed under the next one. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/src/lib/auth.tsx | 2 +- apps/mobile/src/lib/push.test.ts | 138 ++++++++++++ apps/mobile/src/lib/push.ts | 199 ++++++++++++------ apps/mobile/src/lib/push.web.ts | 2 +- supabase/README.md | 4 +- .../migrations/20261014120000_push_tokens.sql | 27 +++ supabase/tests/push_tokens_test.sql | 25 ++- 8 files changed, 334 insertions(+), 65 deletions(-) create mode 100644 apps/mobile/src/lib/push.test.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 5fde8130d..05bf90f2d 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -29,7 +29,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them. Voice rounds need the development build, so the voice switch shows disabled. - 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. -- 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 removes the row while the session still works. 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. +- 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 removes this install's rows with the account's own access token, after any registration still in flight. The local record is cleared only once that delete succeeds; otherwise the next account on the phone releases the token with `release_push_token`. 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.json` has `extra.eas.projectId`, which needs an Expo account and `eas init` (Adam's step). 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.json` does not list it, and the plugin sets the `aps-environment` entitlement. A development build signed with a profile that lacks the push entitlement (the borrowed Signulous one) has to account for that. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. diff --git a/apps/mobile/src/lib/auth.tsx b/apps/mobile/src/lib/auth.tsx index 7911c063f..4a7889998 100644 --- a/apps/mobile/src/lib/auth.tsx +++ b/apps/mobile/src/lib/auth.tsx @@ -145,7 +145,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, 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); + 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.test.ts b/apps/mobile/src/lib/push.test.ts new file mode 100644 index 000000000..1e4f29e67 --- /dev/null +++ b/apps/mobile/src/lib/push.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type Sent = { + kind: 'rpc' | 'delete'; + name: string; + args?: Record; + filters: [string, unknown][]; + 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 = { + delete: () => query, + eq: (column: string, value: unknown) => (request.filters.push([column, value]), 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); + if (request.signal?.aborted) return Promise.resolve({ error: { message: 'AbortError' } }).then(onFulfilled, onRejected); + return server.respond(request).then(onFulfilled, onRejected); + }, + }; + return query; +} + +vi.mock('./supabase', () => ({ + supabase: { + auth: { getSession: async () => ({ data: { session: server.session } }) }, + rpc: (name: string, args: Record) => builder({ kind: 'rpc', name, args, filters: [], headers: {} }), + from: (name: string) => builder({ kind: 'delete', name, filters: [], 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', () => ({ 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'); + +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 removal = server.sent.find((request) => request.kind === 'delete'); + expect(removal).toMatchObject({ + name: 'push_tokens', + filters: [['user_id', 'user-a'], ['installation_id', 'install-1']], + 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, 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_token: TOKEN }, + headers: { Authorization: 'Bearer token-b' }, + }); + expect(server.storage.has(KEY)).toBe(false); + }); + + 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({ kind: 'delete', 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 index 902752cd1..eae6723d9 100644 --- a/apps/mobile/src/lib/push.ts +++ b/apps/mobile/src/lib/push.ts @@ -16,6 +16,12 @@ import { supabase } from './supabase'; * the phone's Expo push token under the signed-in user with * register_push_token, and removes it again on sign-out. * + * Sign-out removes the account's rows with that account's own access token, + * which keeps working until it expires, so a registration still in flight + * when the account signs out is removed as soon as it lands. The local record + * is cleared only once the server removed the rows; if that fails, the next + * account on this phone releases the token with release_push_token. + * * - 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.json), which is not set yet. @@ -24,12 +30,16 @@ import { supabase } from './supabase'; */ export type PushState = 'unavailable' | 'blocked' | 'off' | 'on'; -// The token this install registered, and for whom. Only that user's session -// may remove the row, so a registration left by another account is dropped. +// The token this install registered, and for whom. `confirmed` stays false +// until the server confirms the first registration, so a registration whose +// answer never came is still known here and gets removed. const REGISTRATION_KEY = 'debatelab.push-registration'; -const SIGN_OUT_BUDGET_MS = 5_000; +// How long sign-out waits; the removal 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 }; +type Registration = { userId: string; token: string; confirmed: boolean }; // One change at a time, so sign-out's removal runs after a registration in // flight instead of before it, and the next account never inherits the row. @@ -57,33 +67,92 @@ 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 } = value as Record; - return typeof userId === 'string' && typeof token === 'string' ? { userId, token } : null; + const { userId, token, confirmed } = value as Record; + if (typeof userId !== 'string' || typeof token !== 'string') return null; + // Builds before `confirmed` wrote the record only after the server confirmed it. + return { userId, token, confirmed: confirmed !== false }; } catch { return null; } } -async function register(userId: string, language: AppLanguage): Promise { - const token = (await Notifications.getExpoPushTokenAsync({ projectId: projectId() })).data; - // register_push_token files the token under whoever is signed in now; make sure that is still this user. - const { data } = await supabase.auth.getSession(); - if (data.session?.user.id !== userId) throw new Error('The account changed before the phone was registered.'); - const { error } = await supabase.rpc('register_push_token', { - p_token: token, - p_platform: Platform.OS, - p_locale: language, - p_installation_id: await getInstallId(), - }); +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); + const previous = await untilAborted(readRegistration(), signal); + const confirmed = previous?.userId === userId && previous.confirmed; + // Recorded before asking, so sign-out still removes the row if the answer is lost. + await untilAborted(writeRegistration({ userId, token, confirmed }), signal); + const { error } = await supabase + .rpc('register_push_token', { + p_token: token, + p_platform: Platform.OS, + p_locale: language, + p_installation_id: installationId, + }) + .setHeader('Authorization', `Bearer ${accessToken}`) + .abortSignal(signal); + if (error) throw new Error(error.message); + if (!confirmed) await untilAborted(writeRegistration({ userId, token, confirmed: true }), signal); +} + +/** + * Removes every row this install filed under the user, including one left by + * an older token, then forgets the registration. RLS lets a user delete only + * their own rows, so the request carries that user's access token. + */ +async function removeOwn(userId: string, accessToken: string, signal: AbortSignal): Promise { + const installationId = await untilAborted(getInstallId(), signal); + const { error } = await supabase + .from('push_tokens') + .delete() + .eq('user_id', userId) + .eq('installation_id', installationId) + .setHeader('Authorization', `Bearer ${accessToken}`) + .abortSignal(signal); if (error) throw new Error(error.message); - await AsyncStorage.setItem(REGISTRATION_KEY, JSON.stringify({ userId, token } satisfies Registration)); + await untilAborted(AsyncStorage.removeItem(REGISTRATION_KEY), signal); } -/** Removes this phone's token row. RLS lets a user delete only their own rows. */ -async function removeToken(registration: Registration, signal?: AbortSignal): Promise { - const query = supabase.from('push_tokens').delete().eq('token', registration.token).eq('user_id', registration.userId); - const { error } = await (signal ? query.abortSignal(signal) : query); +/** + * A registration left by another account means its removal on sign-out + * failed. That account's token is gone, so this user releases the row with + * release_push_token before doing anything else with push on this phone. + */ +async function releaseLeftover(userId: string, signal: AbortSignal): Promise { + const registration = await untilAborted(readRegistration(), signal); + if (!registration || registration.userId === userId) return; + const accessToken = await accessTokenOf(userId, signal); + const { error } = await supabase + .rpc('release_push_token', { p_token: registration.token }) + .setHeader('Authorization', `Bearer ${accessToken}`) + .abortSignal(signal); if (error) throw new Error(error.message); + await untilAborted(AsyncStorage.removeItem(REGISTRATION_KEY), signal); } /** Queued behind any change in flight, so a read during a change reports where it ended. */ @@ -91,7 +160,7 @@ 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) return allowed(permissions) ? 'on' : 'blocked'; + if (registration?.userId === userId && registration.confirmed) return allowed(permissions) ? 'on' : 'blocked'; return !allowed(permissions) && !permissions.canAskAgain ? 'blocked' : 'off'; }); } @@ -100,62 +169,74 @@ export function readPushState(userId: string): Promise { 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 register(userId, language); + await withDeadline(async (signal) => { + await releaseLeftover(userId, signal); + await register(userId, language, signal); + }); return 'on'; }); } -/** Stops this user's notifications on this phone. Throws if the row could not be removed, so the switch stays 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(async () => { - const registration = await readRegistration(); - if (registration?.userId === userId) await removeToken(registration); - await AsyncStorage.removeItem(REGISTRATION_KEY); - }); + return serial(() => + withDeadline(async (signal) => { + await releaseLeftover(userId, signal); + await removeOwn(userId, 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. A - * registration left by another account (its session ended without signing - * out here) is forgotten; the next opt-in moves the token. + * hand out a new token and the server writes in the token's language. It + * first releases a token another account left behind, and removes a first + * registration whose answer never came, since the switch showed it as off. */ export function refreshPush(userId: string, language: AppLanguage): Promise { - return serial(async () => { - const registration = await readRegistration(); - if (!registration) return; - if (registration.userId !== userId) { - await AsyncStorage.removeItem(REGISTRATION_KEY); - return; - } - if (!available() || !allowed(await Notifications.getPermissionsAsync())) return; - await register(userId, language); - }); + return serial(() => + withDeadline(async (signal) => { + await releaseLeftover(userId, signal); + const registration = await untilAborted(readRegistration(), signal); + if (registration?.userId !== userId) return; + if (!registration.confirmed) { + await removeOwn(userId, await accessTokenOf(userId, signal), signal); + return; + } + if (!available() || !allowed(await Notifications.getPermissionsAsync())) return; + await register(userId, language, signal); + }), + ); } /** - * Called by sign-out while the session still works, so the next person on - * this phone never gets this account's notifications. Never throws, and gives - * up after five seconds so an offline sign-out still signs out. + * 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 removal 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 + * removal fails, the registration stays recorded and the next account on this + * phone releases the token (see releaseLeftover). */ -export async function forgetPushOnSignOut(userId: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), SIGN_OUT_BUDGET_MS); - const forget = serial(async () => { - try { - const registration = await readRegistration(); - if (registration?.userId === userId) await removeToken(registration, controller.signal); - } finally { - await AsyncStorage.removeItem(REGISTRATION_KEY); - } +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 removeOwn(userId, accessToken, signal); + }), + ).catch((cause: unknown) => { + console.warn('Could not remove 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, controller.signal); - } catch (cause) { - console.warn('Could not remove the push token on sign-out:', cause instanceof Error ? cause.message : cause); + await untilAborted(forget, wait.signal); + } catch { + // Still waiting for a registration in flight; the removal 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 index cf3d0b8ae..d0d05cf1c 100644 --- a/apps/mobile/src/lib/push.web.ts +++ b/apps/mobile/src/lib/push.web.ts @@ -29,7 +29,7 @@ export function refreshPush(_userId: string, _language: AppLanguage): Promise { +export function forgetPushOnSignOut(_userId: string, _accessToken: string): Promise { enabledFor = null; return Promise.resolve(); } diff --git a/supabase/README.md b/supabase/README.md index 78e4b3bd2..d28538cd0 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -82,7 +82,7 @@ Move code into `_shared` only when multiple functions use the same boundary or p ## Push notifications -The iOS app files its Expo push token through the `register_push_token` RPC (`20261014120000_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, and turns a disabled token back on. 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. +The iOS app files its Expo push token through the `register_push_token` RPC (`20261014120000_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. Sign-out deletes the account's rows for that install with the account's own access token, after any registration still in flight; the app keeps its local record until the delete succeeds. If it never does, the next account on the phone calls `release_push_token`, which deletes a token whatever account holds it: holding the token is the proof, as it already is for `register_push_token`. 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. @@ -142,7 +142,7 @@ 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. -`20261014120000_push_tokens.sql` is additive: it creates `push_tokens`, `push_tickets` and `register_push_token`. See [Push notifications](#push-notifications). +`20261014120000_push_tokens.sql` is additive: it creates `push_tokens`, `push_tickets`, `register_push_token` and `release_push_token`. See [Push notifications](#push-notifications). `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. diff --git a/supabase/migrations/20261014120000_push_tokens.sql b/supabase/migrations/20261014120000_push_tokens.sql index 2eed82454..91a6f0bf9 100644 --- a/supabase/migrations/20261014120000_push_tokens.sql +++ b/supabase/migrations/20261014120000_push_tokens.sql @@ -7,6 +7,10 @@ -- 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 @@ -107,3 +111,26 @@ $$; 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/tests/push_tokens_test.sql b/supabase/tests/push_tokens_test.sql index aadde0761..1de25dc19 100644 --- a/supabase/tests/push_tokens_test.sql +++ b/supabase/tests/push_tokens_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(20); +SELECT plan(23); INSERT INTO auth.users (id) VALUES ('f1111111-1111-4111-8111-111111111111'), @@ -32,6 +32,12 @@ SELECT ok( AND NOT has_function_privilege('service_role', 'public.register_push_token(text, text, text, 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(text)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.release_push_token(text)', 'EXECUTE') + AND NOT has_function_privilege('service_role', 'public.release_push_token(text)', '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); @@ -151,6 +157,17 @@ SELECT ok( ); SET LOCAL ROLE authenticated; +-- The account that registered the phone signed out without removing its row; +-- the next account on the phone releases it. +SELECT set_config('request.jwt.claim.sub', 'f1111111-1111-4111-8111-111111111111', true); +SELECT public.release_push_token('ExponentPushToken[eeeeeeeeeeeeeeeeeeeeee]'); +RESET ROLE; +SELECT ok( + (SELECT count(*) = 9 AND NOT bool_or(token = 'ExponentPushToken[eeeeeeeeeeeeeeeeeeeeee]') FROM public.push_tokens), + 'the next account on the phone releases a token another account holds, and only that token' +); +SET LOCAL ROLE authenticated; + SELECT set_config('request.jwt.claim.sub', '', true); SELECT throws_ok( $$SELECT public.register_push_token('ExponentPushToken[ffffffffffffffffffffff]', 'ios', 'en', NULL)$$, @@ -158,6 +175,12 @@ SELECT throws_ok( NULL, 'a token cannot be registered without a signed-in user' ); +SELECT throws_ok( + $$SELECT public.release_push_token('ExponentPushToken[dddddddddddddddddddddd]')$$, + '42501', + NULL, + 'a token cannot be released without a signed-in user' +); RESET ROLE; DELETE FROM auth.users WHERE id = 'f2222222-2222-4222-8222-222222222222'; From 14ead8e2874aab9295f2cc81086492b1efc23ce0 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 21:53:40 +0000 Subject: [PATCH 11/12] Refuse a push registration that reaches the database after its release Cancelling an HTTP request does not cancel its database statement. If account A's registration hit the 20 second deadline, sign-out's delete could run first, see no row and succeed, and the app cleared its local record. When the registration committed afterwards, nothing on the phone knew about it, so A's notifications kept reaching the next account. - Every registration carries the ID of the app's local registration record. release_push_token withdraws that ID and deletes the rows it wrote, the phone's token and the caller's own rows from the install. register_push_token refuses a withdrawn ID. Both hold the ID's advisory lock until they commit, so a release after a registration deletes its row and a registration after a release is refused. - Sign-out, the switch and the next account's cleanup all release through that RPC, and the local record is cleared only after a release succeeds. - A test reproduces the late commit. It fails on the previous code and passes now. pgTAP covers refusal, deletion by registration ID and the lock. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/src/lib/push.test.ts | 71 +++++++-- apps/mobile/src/lib/push.ts | 117 +++++++------- supabase/README.md | 4 +- ...014200000_push_registration_withdrawal.sql | 143 ++++++++++++++++++ supabase/tests/push_tokens_test.sql | 92 ++++++++--- 6 files changed, 331 insertions(+), 98 deletions(-) create mode 100644 supabase/migrations/20261014200000_push_registration_withdrawal.sql diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 05bf90f2d..d9d7edd25 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -29,7 +29,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them. Voice rounds need the development build, so the voice switch shows disabled. - 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. -- 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 removes this install's rows with the account's own access token, after any registration still in flight. The local record is cleared only once that delete succeeds; otherwise the next account on the phone releases the token with `release_push_token`. 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. +- 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, so a registration the app gave up on is refused if it reaches the database after the release. The local record is cleared only once a release succeeds; otherwise the next account on the phone 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.json` has `extra.eas.projectId`, which needs an Expo account and `eas init` (Adam's step). 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.json` does not list it, and the plugin sets the `aps-environment` entitlement. A development build signed with a profile that lacks the push entitlement (the borrowed Signulous one) has to account for that. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. diff --git a/apps/mobile/src/lib/push.test.ts b/apps/mobile/src/lib/push.test.ts index 1e4f29e67..7d4d26568 100644 --- a/apps/mobile/src/lib/push.test.ts +++ b/apps/mobile/src/lib/push.test.ts @@ -1,10 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; type Sent = { - kind: 'rpc' | 'delete'; name: string; - args?: Record; - filters: [string, unknown][]; + args: Record; headers: Record; signal?: AbortSignal; }; @@ -19,14 +17,18 @@ const server = vi.hoisted(() => ({ function builder(request: Sent) { const query = { - delete: () => query, - eq: (column: string, value: unknown) => (request.filters.push([column, value]), 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); - if (request.signal?.aborted) return Promise.resolve({ error: { message: 'AbortError' } }).then(onFulfilled, onRejected); - return server.respond(request).then(onFulfilled, onRejected); + 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; @@ -35,8 +37,7 @@ function builder(request: Sent) { vi.mock('./supabase', () => ({ supabase: { auth: { getSession: async () => ({ data: { session: server.session } }) }, - rpc: (name: string, args: Record) => builder({ kind: 'rpc', name, args, filters: [], headers: {} }), - from: (name: string) => builder({ kind: 'delete', name, filters: [], headers: {} }), + rpc: (name: string, args: Record) => builder({ name, args, headers: {} }), }, })); vi.mock('@react-native-async-storage/async-storage', () => ({ @@ -47,6 +48,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ }, })); 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 } })); @@ -98,17 +100,17 @@ describe('push token removal on sign-out', () => { registration.resolve({ error: null }); await expect(enabled).resolves.toBe('on'); await vi.waitFor(() => expect(server.storage.has(KEY)).toBe(false)); - const removal = server.sent.find((request) => request.kind === 'delete'); + 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({ - name: 'push_tokens', - filters: [['user_id', 'user-a'], ['installation_id', 'install-1']], + args: { p_token: TOKEN, 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, confirmed: true })); + 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); @@ -118,12 +120,51 @@ describe('push token removal on sign-out', () => { await refreshPush('user-b', 'en'); expect(server.sent.at(-1)).toMatchObject({ name: 'release_push_token', - args: { p_token: TOKEN }, + args: { p_token: TOKEN, 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 () => { + // A small model of the database: rows, and the registrations a release withdrew. + 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.token !== args.p_token && row.registration !== args.p_registration); + } + return { error: null }; + }; + // 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(() => apply(request)) : Promise.resolve(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('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 }); @@ -132,7 +173,7 @@ describe('push token removal on sign-out', () => { // The server may have stored the row before the answer was lost. await forgetPushOnSignOut('user-a', 'token-a'); - expect(server.sent.at(-1)).toMatchObject({ kind: 'delete', headers: { Authorization: 'Bearer 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 index eae6723d9..bf8947ead 100644 --- a/apps/mobile/src/lib/push.ts +++ b/apps/mobile/src/lib/push.ts @@ -2,6 +2,7 @@ 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'; @@ -16,11 +17,14 @@ import { supabase } from './supabase'; * the phone's Expo push token under the signed-in user with * register_push_token, and removes it again on sign-out. * - * Sign-out removes the account's rows with that account's own access token, - * which keeps working until it expires, so a registration still in flight - * when the account signs out is removed as soon as it lands. The local record - * is cleared only once the server removed the rows; if that fails, the next - * account on this phone releases the token with release_push_token. + * Every registration carries the ID of the local registration record, and + * release_push_token withdraws that ID while it deletes the rows. 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. 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 another account on this + * phone releases it 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 @@ -30,18 +34,21 @@ import { supabase } from './supabase'; */ export type PushState = 'unavailable' | 'blocked' | 'off' | 'on'; -// The token this install registered, and for whom. `confirmed` stays false -// until the server confirms the first registration, so a registration whose -// answer never came is still known here and gets removed. +// 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. const REGISTRATION_KEY = 'debatelab.push-registration'; -// How long sign-out waits; the removal itself carries on in the background. +// 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; confirmed: boolean }; +type Registration = { userId: string; token: string; id: string; confirmed: boolean }; -// One change at a time, so sign-out's removal runs after a registration in +// 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(); @@ -67,10 +74,9 @@ 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, confirmed } = value as Record; - if (typeof userId !== 'string' || typeof token !== 'string') return null; - // Builds before `confirmed` wrote the record only after the server confirmed it. - return { userId, token, confirmed: confirmed !== false }; + const { userId, token, id, confirmed } = value as Record; + if (typeof userId !== 'string' || typeof token !== 'string' || typeof id !== 'string') return null; + return { userId, token, id, confirmed: confirmed === true }; } catch { return null; } @@ -103,35 +109,43 @@ async function register(userId: string, language: AppLanguage, signal: AbortSign // 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. const previous = await untilAborted(readRegistration(), signal); - const confirmed = previous?.userId === userId && previous.confirmed; - // Recorded before asking, so sign-out still removes the row if the answer is lost. - await untilAborted(writeRegistration({ userId, token, confirmed }), signal); + const registration = + previous?.userId === userId ? { ...previous, token } : { userId, token, id: Crypto.randomUUID(), confirmed: 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 (!confirmed) await untilAborted(writeRegistration({ userId, token, confirmed: true }), signal); + if (!registration.confirmed) await untilAborted(writeRegistration({ ...registration, confirmed: true }), signal); } /** - * Removes every row this install filed under the user, including one left by - * an older token, then forgets the registration. RLS lets a user delete only - * their own rows, so the request carries that user's access token. + * Withdraws the registration recorded here, whoever made it, and deletes this + * phone's rows: the ones it wrote, its token, and any row this install filed + * under the caller, then forgets it. Any signed-in account may release: the + * registration ID and the token are the proof. */ -async function removeOwn(userId: string, accessToken: string, signal: AbortSignal): Promise { +async function release(accessToken: string, signal: AbortSignal): Promise { + const registration = await untilAborted(readRegistration(), signal); const installationId = await untilAborted(getInstallId(), signal); const { error } = await supabase - .from('push_tokens') - .delete() - .eq('user_id', userId) - .eq('installation_id', installationId) + .rpc('release_push_token', { + p_token: registration?.token ?? null, + p_installation_id: installationId, + p_registration: registration?.id ?? null, + }) .setHeader('Authorization', `Bearer ${accessToken}`) .abortSignal(signal); if (error) throw new Error(error.message); @@ -139,20 +153,15 @@ async function removeOwn(userId: string, accessToken: string, signal: AbortSigna } /** - * A registration left by another account means its removal on sign-out - * failed. That account's token is gone, so this user releases the row with - * release_push_token before doing anything else with push on this phone. + * 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, or a first registration of this user's whose answer never + * came, since the switch showed it as off. */ -async function releaseLeftover(userId: string, signal: AbortSignal): Promise { +async function releaseStale(userId: string, signal: AbortSignal): Promise { const registration = await untilAborted(readRegistration(), signal); - if (!registration || registration.userId === userId) return; - const accessToken = await accessTokenOf(userId, signal); - const { error } = await supabase - .rpc('release_push_token', { p_token: registration.token }) - .setHeader('Authorization', `Bearer ${accessToken}`) - .abortSignal(signal); - if (error) throw new Error(error.message); - await untilAborted(AsyncStorage.removeItem(REGISTRATION_KEY), signal); + if (!registration || (registration.userId === userId && registration.confirmed)) return; + await release(await accessTokenOf(userId, signal), signal); } /** Queued behind any change in flight, so a read during a change reports where it ended. */ @@ -174,7 +183,7 @@ export function enablePush(userId: string, language: AppLanguage): Promise { - await releaseLeftover(userId, signal); + await releaseStale(userId, signal); await register(userId, language, signal); }); return 'on'; @@ -183,30 +192,20 @@ export function enablePush(userId: string, language: AppLanguage): Promise { - return serial(() => - withDeadline(async (signal) => { - await releaseLeftover(userId, signal); - await removeOwn(userId, await accessTokenOf(userId, signal), signal); - }), - ); + 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 token another account left behind, and removes a first - * registration whose answer never came, since the switch showed it as off. + * 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 releaseLeftover(userId, signal); + await releaseStale(userId, signal); const registration = await untilAborted(readRegistration(), signal); if (registration?.userId !== userId) return; - if (!registration.confirmed) { - await removeOwn(userId, await accessTokenOf(userId, signal), signal); - return; - } if (!available() || !allowed(await Notifications.getPermissionsAsync())) return; await register(userId, language, signal); }), @@ -216,27 +215,27 @@ export function refreshPush(userId: string, language: AppLanguage): Promise { const forget = serial(() => withDeadline(async (signal) => { const registration = await untilAborted(readRegistration(), signal); - if (registration?.userId === userId) await removeOwn(userId, accessToken, signal); + if (registration?.userId === userId) await release(accessToken, signal); }), ).catch((cause: unknown) => { - console.warn('Could not remove the push token on sign-out:', cause instanceof Error ? cause.message : cause); + 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 removal runs once it lands. + // Still waiting for a registration in flight; the release runs once it lands. } finally { clearTimeout(timeout); } diff --git a/supabase/README.md b/supabase/README.md index d28538cd0..a2f7e0b60 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -82,7 +82,7 @@ Move code into `_shared` only when multiple functions use the same boundary or p ## Push notifications -The iOS app files its Expo push token through the `register_push_token` RPC (`20261014120000_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. Sign-out deletes the account's rows for that install with the account's own access token, after any registration still in flight; the app keeps its local record until the delete succeeds. If it never does, the next account on the phone calls `release_push_token`, which deletes a token whatever account holds it: holding the token is the proof, as it already is for `register_push_token`. 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. +The iOS app files its Expo push token through the `register_push_token` RPC (`20261014120000_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. `release_push_token` deletes the token whatever account holds it, the rows the app's registration wrote and the caller's own rows from that install: holding the token or the registration ID is the proof, as the token already is for `register_push_token`. 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. 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. @@ -142,7 +142,7 @@ 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. -`20261014120000_push_tokens.sql` is additive: it creates `push_tokens`, `push_tickets`, `register_push_token` and `release_push_token`. See [Push notifications](#push-notifications). +`20261014120000_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. See [Push notifications](#push-notifications). `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. 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/tests/push_tokens_test.sql b/supabase/tests/push_tokens_test.sql index 1de25dc19..6896bd962 100644 --- a/supabase/tests/push_tokens_test.sql +++ b/supabase/tests/push_tokens_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(23); +SELECT plan(29); INSERT INTO auth.users (id) VALUES ('f1111111-1111-4111-8111-111111111111'), @@ -27,15 +27,22 @@ SELECT ok( 'push tickets belong to the server only' ); SELECT ok( - has_function_privilege('authenticated', 'public.register_push_token(text, text, text, uuid)', 'EXECUTE') - AND NOT has_function_privilege('anon', 'public.register_push_token(text, text, text, uuid)', 'EXECUTE') - AND NOT has_function_privilege('service_role', 'public.register_push_token(text, text, text, uuid)', 'EXECUTE'), + 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(text)', 'EXECUTE') - AND NOT has_function_privilege('anon', 'public.release_push_token(text)', 'EXECUTE') - AND NOT has_function_privilege('service_role', 'public.release_push_token(text)', 'EXECUTE'), + has_function_privilege('authenticated', 'public.release_push_token(text, uuid, uuid)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.release_push_token(text, uuid, uuid)', 'EXECUTE') + AND NOT has_function_privilege('service_role', 'public.release_push_token(text, uuid, uuid)', 'EXECUTE'), 'only a signed-in user can release a token' ); @@ -43,7 +50,7 @@ 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')$$, + $$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( @@ -59,13 +66,19 @@ SELECT throws_ok( '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)$$, + $$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', 'fr', NULL)$$, + $$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' @@ -84,7 +97,7 @@ SELECT is_empty( -- 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')$$, + $$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( @@ -106,14 +119,14 @@ INSERT INTO public.push_tickets (id, token) VALUES ('ticket-1', 'ExponentPushTok 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'); +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'); +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)$$, @@ -133,7 +146,7 @@ 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'); +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' @@ -155,12 +168,25 @@ SELECT ok( ), '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; -- The account that registered the phone signed out without removing its row; -- the next account on the phone releases it. SELECT set_config('request.jwt.claim.sub', 'f1111111-1111-4111-8111-111111111111', true); -SELECT public.release_push_token('ExponentPushToken[eeeeeeeeeeeeeeeeeeeeee]'); +SELECT public.release_push_token('ExponentPushToken[eeeeeeeeeeeeeeeeeeeeee]', NULL, NULL); RESET ROLE; SELECT ok( (SELECT count(*) = 9 AND NOT bool_or(token = 'ExponentPushToken[eeeeeeeeeeeeeeeeeeeeee]') FROM public.push_tokens), @@ -168,15 +194,39 @@ SELECT ok( ); 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(NULL, 'fa000000-0000-4000-8000-000000000003', 'ab000000-0000-4000-8000-000000000004'); +RESET ROLE; +SELECT ok( + (SELECT count(*) = 9 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' +); + SELECT set_config('request.jwt.claim.sub', '', true); SELECT throws_ok( - $$SELECT public.register_push_token('ExponentPushToken[ffffffffffffffffffffff]', 'ios', 'en', NULL)$$, + $$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('ExponentPushToken[dddddddddddddddddddddd]')$$, + $$SELECT public.release_push_token('ExponentPushToken[dddddddddddddddddddddd]', NULL, NULL)$$, '42501', NULL, 'a token cannot be released without a signed-in user' @@ -184,10 +234,10 @@ SELECT throws_ok( RESET ROLE; DELETE FROM auth.users WHERE id = 'f2222222-2222-4222-8222-222222222222'; -SELECT is( - (SELECT count(*) FROM public.push_tokens)::integer, - 0, - 'deleting an account removes its tokens' +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(); From 2167cad70ae3aa7aca80293ff0b07f8cc53b6814 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 22:54:57 +0000 Subject: [PATCH 12/12] Keep a late push release from deleting a newer registration release_push_token deleted the phone's token whatever registration had written it, and every row the caller's install held. If account A's sign-out release timed out but still reached the database later, it could run after account B had released A's registration and turned notifications on with the same token, and delete B's row. B's switch stayed on while its notifications stopped. The same happened to A itself when it signed back in and registered again under the old ID. - release_push_token(p_installation_id, p_registration) replaces the version that took the token. It deletes only rows whose registration_id is the released one, plus the caller's own rows from the install that carry no registration ID. Withdrawal and the refusal of a late registration commit are unchanged. - The app marks its local record released before it sends a release. A released record is released again before any other push change and reads as off, so the next registration always gets a new ID and a late release cannot reach it. - pgTAP reproduces both orderings (another account and the same account) and fails on the previous function. A push.test.ts case reproduces the same-account ordering and fails on the previous push.ts. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/src/lib/push.test.ts | 69 ++++++++++++++----- apps/mobile/src/lib/push.ts | 52 ++++++++------ supabase/README.md | 4 +- ...014220000_push_release_by_registration.sql | 57 +++++++++++++++ supabase/tests/push_tokens_test.sql | 62 +++++++++++++---- 6 files changed, 189 insertions(+), 57 deletions(-) create mode 100644 supabase/migrations/20261014220000_push_release_by_registration.sql diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 32884c13d..68c70b66e 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -31,7 +31,7 @@ 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, so a registration the app gave up on is refused if it reaches the database after the release. The local record is cleared only once a release succeeds; otherwise the next account on the phone 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. +- 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. diff --git a/apps/mobile/src/lib/push.test.ts b/apps/mobile/src/lib/push.test.ts index 7d4d26568..929c33558 100644 --- a/apps/mobile/src/lib/push.test.ts +++ b/apps/mobile/src/lib/push.test.ts @@ -68,6 +68,26 @@ 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)); @@ -103,7 +123,7 @@ describe('push token removal on sign-out', () => { 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_token: TOKEN, p_installation_id: 'install-1', p_registration: registered?.args.p_registration }, + args: { p_installation_id: 'install-1', p_registration: registered?.args.p_registration }, headers: { Authorization: 'Bearer token-a' }, }); expect(removal?.signal?.aborted).toBe(false); @@ -120,33 +140,18 @@ describe('push token removal on sign-out', () => { await refreshPush('user-b', 'en'); expect(server.sent.at(-1)).toMatchObject({ name: 'release_push_token', - args: { p_token: TOKEN, p_registration: 'registration-a' }, + 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 () => { - // A small model of the database: rows, and the registrations a release withdrew. - 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.token !== args.p_token && row.registration !== args.p_registration); - } - return { error: null }; - }; + 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(() => apply(request)) : Promise.resolve(apply(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')); @@ -165,6 +170,32 @@ describe('push token removal on sign-out', () => { 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 }); diff --git a/apps/mobile/src/lib/push.ts b/apps/mobile/src/lib/push.ts index 980c1070a..40b1406f2 100644 --- a/apps/mobile/src/lib/push.ts +++ b/apps/mobile/src/lib/push.ts @@ -18,13 +18,15 @@ import { supabase } from './supabase'; * 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 while it deletes the rows. 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. 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 another account on this - * phone releases it before doing anything else with push. + * 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 @@ -37,7 +39,8 @@ 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. `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'; @@ -46,7 +49,7 @@ 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 }; +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. @@ -74,9 +77,9 @@ 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 } = value as Record; + 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 }; + return { userId, token, id, confirmed: confirmed === true, released: released === true }; } catch { return null; } @@ -109,10 +112,12 @@ async function register(userId: string, language: AppLanguage, signal: AbortSign // 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. + // 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 }; + 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 @@ -132,17 +137,17 @@ async function register(userId: string, language: AppLanguage, signal: AbortSign } /** - * Withdraws the registration recorded here, whoever made it, and deletes this - * phone's rows: the ones it wrote, its token, and any row this install filed - * under the caller, then forgets it. Any signed-in account may release: the - * registration ID and the token are the proof. + * 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_token: registration?.token ?? null, p_installation_id: installationId, p_registration: registration?.id ?? null, }) @@ -155,12 +160,13 @@ async function release(accessToken: string, signal: AbortSignal): Promise /** * 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, or a first registration of this user's whose answer never - * came, since the switch showed it as off. + * 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)) return; + if (!registration || (registration.userId === userId && registration.confirmed && !registration.released)) return; await release(await accessTokenOf(userId, signal), signal); } @@ -169,7 +175,9 @@ 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) return allowed(permissions) ? 'on' : 'blocked'; + if (registration?.userId === userId && registration.confirmed && !registration.released) { + return allowed(permissions) ? 'on' : 'blocked'; + } return !allowed(permissions) && !permissions.canAskAgain ? 'blocked' : 'off'; }); } diff --git a/supabase/README.md b/supabase/README.md index 70d47266b..2adf66ed5 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -88,7 +88,7 @@ Move code into `_shared` only when multiple functions use the same boundary or p ## 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. `release_push_token` deletes the token whatever account holds it, the rows the app's registration wrote and the caller's own rows from that install: holding the token or the registration ID is the proof, as the token already is for `register_push_token`. 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. 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. +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. @@ -148,7 +148,7 @@ 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. See [Push notifications](#push-notifications). +`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. 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/push_tokens_test.sql b/supabase/tests/push_tokens_test.sql index 6896bd962..a862fd3e3 100644 --- a/supabase/tests/push_tokens_test.sql +++ b/supabase/tests/push_tokens_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(29); +SELECT plan(31); INSERT INTO auth.users (id) VALUES ('f1111111-1111-4111-8111-111111111111'), @@ -40,9 +40,9 @@ SELECT ok( 'only a signed-in user can register a token, and only for themselves' ); SELECT ok( - has_function_privilege('authenticated', 'public.release_push_token(text, uuid, uuid)', 'EXECUTE') - AND NOT has_function_privilege('anon', 'public.release_push_token(text, uuid, uuid)', 'EXECUTE') - AND NOT has_function_privilege('service_role', 'public.release_push_token(text, uuid, uuid)', 'EXECUTE'), + 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' ); @@ -183,14 +183,18 @@ SELECT ok( ); SET LOCAL ROLE authenticated; --- The account that registered the phone signed out without removing its row; --- the next account on the phone releases it. +-- 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('ExponentPushToken[eeeeeeeeeeeeeeeeeeeeee]', NULL, NULL); +SELECT public.release_push_token('fa000000-0000-4000-8000-000000000002', NULL); RESET ROLE; SELECT ok( - (SELECT count(*) = 9 AND NOT bool_or(token = 'ExponentPushToken[eeeeeeeeeeeeeeeeeeeeee]') FROM public.push_tokens), - 'the next account on the phone releases a token another account holds, and only that token' + (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; @@ -199,10 +203,10 @@ SET LOCAL ROLE authenticated; 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(NULL, 'fa000000-0000-4000-8000-000000000003', 'ab000000-0000-4000-8000-000000000004'); +SELECT public.release_push_token('fa000000-0000-4000-8000-000000000003', 'ab000000-0000-4000-8000-000000000004'); RESET ROLE; SELECT ok( - (SELECT count(*) = 9 AND NOT bool_or(token = 'ExponentPushToken[gggggggggggggggggggggg]') FROM public.push_tokens), + (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; @@ -218,6 +222,38 @@ SELECT lives_ok( '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')$$, @@ -226,10 +262,10 @@ SELECT throws_ok( 'a token cannot be registered without a signed-in user' ); SELECT throws_ok( - $$SELECT public.release_push_token('ExponentPushToken[dddddddddddddddddddddd]', NULL, NULL)$$, + $$SELECT public.release_push_token('fa000000-0000-4000-8000-000000000001', NULL)$$, '42501', NULL, - 'a token cannot be released without a signed-in user' + 'nothing can be released without a signed-in user' ); RESET ROLE;