Skip to content

Native iOS app: push notifications (backend and opt-in) - #140

Merged
Programcode merged 14 commits into
feat/native-iosfrom
native-ios/07b-push
Oct 14, 2026
Merged

Programcode merged 14 commits into
feat/native-iosfrom
native-ios/07b-push

Conversation

@Programcode

Copy link
Copy Markdown
Owner

Push notifications: the backend, and the opt-in in the app. Nothing is deployed. Real delivery to a phone needs an Expo project id (eas init) and, for iOS, an APNs key.

What's in it

  • Migration 20261014120000_push_tokens.sql.
    • push_tokens: the token is the primary key, and only Expo token formats are accepted. A user can read and delete their own rows only, and clients can't insert or update.
    • All writes go through the register_push_token RPC. It files the token under the caller and moves it from another account when a second person signs in on the same phone. It also replaces the old token from the same install, caps each account at 10 tokens, and turns a disabled token back on.
    • push_tickets is service-only and holds no user id.
    • pgTAP: 19 checks covering isolation, moving a token between accounts, the cap and account deletion.
  • Sender _shared/push.ts.
    • Sends through the Expo push API in batches of 100, then follows up on receipts.
    • Turns off DeviceNotRegistered tokens.
    • Builds every message only for the user who owns the token. The text is fixed and translated, with no debate content on the lock screen.
    • A failed send never fails the request that triggered it.
  • Trigger wired: sparring_scored. It fires after the scorecard is saved in ai.
  • Triggers documented as follow-ups in supabase/README.md: trial_ending (it needs a scheduled job) and announcements.
  • App.
    • A Notifications switch in You, with "blocked" and "unavailable" states.
    • It registers again on launch and when the language changes.
    • Sign-out removes the token, bounded at 5 s.
    • Strings are under mobile.you.notifications.* in en, es and hu.

Checks (local; GitHub Actions is out of minutes)

  • Before merging feat, at 204eca6:
    • ./.superset/db-gate.sh exit 0: pgTAP 20 files and 438 tests, and the full-stack browser suite 71 passed.
    • npm run verify green, including test:edge 342/342.
  • After merging feat (Sparring; no supabase changes), at this head:
    • npm run verify exit 0: vitest 311/311, collaboration 30/30, edge OK, build OK, smoke 30/30.
    • The mobile typecheck and npx eslint apps/mobile: exit 0.
  • Screenshots: the You tab with the switch, on iPhone light and dark, en and hu.

Not provable yet

  • Tokens and delivery on a device. No Expo project id exists yet, and Expo says remote push isn't supported in iOS Expo Go.
  • Signing risk for the development build. Prebuild auto-applies the expo-notifications plugin, which adds the aps-environment entitlement. The borrowed Signulous profile has no push entitlement, so the dev build has to handle that; it's tracked in PR 9a.

Codex review rounds (xhigh, security-sensitive) follow as comments.

🤖 Generated with Claude Code

Programcode and others added 8 commits October 14, 2026 12:25
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…/07b-push

# Conflicts:
#	apps/mobile/AGENTS.md
#	apps/mobile/scripts/screenshots.ts
@vercel

vercel Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
debate-navigator Ready Ready Preview Oct 14, 2026 11:02pm UTC
debatelab-staging Ready Ready Preview Oct 14, 2026 11:02pm UTC

Copilot AI balanced review requested due to automatic review settings October 14, 2026 13:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository YAML (base), Organization UI (inherited)
  • Review profile: CHILL
  • Plan: Free
  • Run ID: b4606f92-b35f-4d71-a74b-79261adf9db5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

@Programcode

Copy link
Copy Markdown
Owner Author

Local checks at 3af8227 (after merging feat), each under the shared lock: npm run verify exit 0 (vitest 311/311, collaboration 30/30, edge OK, build OK, smoke 30/30), mobile typecheck exit 0, npx eslint apps/mobile exit 0, npx knip exit 0 (two existing config hints). The database gate passed before the merge (438 pgTAP, 71 browser); the merge brought no supabase changes. GitHub Actions jobs fail in 2 s with no logs (out of minutes), so these are local results, not CI.

@Programcode

Copy link
Copy Markdown
Owner Author

Codex review r1 at 3af8227 (gpt-6.1-sol, xhigh): 3 Medium findings, accepted; fixes in progress.

  1. Sign-out can leave the token enabled: if registration is still pending after the 5 s bound, the queued delete later runs with an aborted signal and fails, but the local record is cleared, so the next user on the phone could get the previous account's notifications.
  2. Backgrounding during a toggle leaves the Notifications switch busy forever (the load sequence and the toggle share one counter).
  3. Concurrent register_push_token calls can exceed the 10-token cap under READ COMMITTED; registration needs a per-user lock.
    Codex otherwise found RLS, grants, definer authorisation, sender ownership and content, tickets, account deletion and the ai response path sound.

Programcode and others added 2 commits October 14, 2026 13:56
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ands 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 <noreply@anthropic.com>
@Programcode

Copy link
Copy Markdown
Owner Author

Round-1 fixes pushed at a178cf7: c006516 serializes token registration per account (cap respected under concurrency), 4d87f70 keeps the Notifications switch usable when the app is backgrounded mid-change, a178cf7 removes a signed-out account's token even when its registration lands late. Local at a178cf7: npm run verify green, mobile typecheck, eslint, knip pass; pgTAP passed on an alternate port (the shared 54322 port was held by an unrelated process, so the full db gate wrapper couldn't start). Codex r2 (xhigh) running.

@Programcode

Copy link
Copy Markdown
Owner Author

Codex review r2 at a178cf7 (gpt-6.1-sol, xhigh): r1's three fixes hold (lock, RPC authorization, token transfer, additive migration). One new Medium, accepted, fix in progress: push.ts:138, sign-out cleanup can clear the local registration record while an earlier register_push_token call is still uncommitted server-side (HTTP cancellation doesn't cancel the statement), so the previous account's token can end up enabled on the next account's phone.

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 <noreply@anthropic.com>
@Programcode

Copy link
Copy Markdown
Owner Author

Round-2 fix 14ead8e (local, pushed with the next merge): the server now refuses a released registration. Each local registration carries a random id; release_push_token records it as withdrawn and deletes its rows in one transaction; register_push_token refuses a withdrawn id; both take an advisory lock on the id, so a late commit is either deleted by the release or refused. New forward-only migration 20261014200000_push_registration_withdrawal.sql. The regression test reproducing the late-commit ordering fails before and passes after; a two-session race on real Postgres left 0 rows both ways. Local: verify green (317 vitest, edge 342), db gate 448 pgTAP PASS, typecheck, eslint, knip. Merging feat/native-ios next (conflicts with #139/#141/#142), then Codex r3.

Brings in Sparring (#138), development-build prep (#142), App Store
entitlements (#141) and offline reading (#139).

- The push migration becomes 20261014130000_push_tokens.sql so it sorts
  after 20261014123154_app_store_entitlements.sql.
- auth.tsx and _layout.tsx keep both the push wiring (release on sign-out,
  refresh on launch and language change) and setCacheOwner/watchNetwork.
- package-lock.json is feat's lock plus `npm install` for expo-notifications.
- app.config.ts is unchanged: #142 already strips aps-environment from
  development builds. The mobile guide now says so, and that real push needs
  a profile with push and an APNs key from Adam's Apple Developer account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Programcode

Copy link
Copy Markdown
Owner Author

Merged feat/native-ios as 2cfee5e (Sparring, dev-build prep, entitlements, offline reading). auth.tsx keeps both the offline cache owner and the push release on sign-out; _layout.tsx keeps watchNetwork and refreshPush; lockfile regenerated with npm install and proven with npm ci; push_tokens migration renamed to 20261014130000 so both push migrations sort after the entitlements migration (never deployed anywhere). Local at 2cfee5e: npm run verify green (342 vitest, edge 380), db gate 531 pgTAP PASS, mobile typecheck, eslint, knip. Codex r3 (xhigh) next.

@Programcode

Copy link
Copy Markdown
Owner Author

Codex review r3 at 2cfee5e (gpt-6.1-sol, xhigh): withdrawal ordering, definer authorization and search paths, RLS/grants, migration ordering, the merged push/offline-cache hooks, lockfile and the unchanged Expo Go config all checked. One new Medium, accepted, fix in progress: release_push_token deletes by token regardless of registration id, so a delayed release of A's registration can delete B's newer registration of the same token (B's switch stays on but notifications stop). The fix scopes a release to the rows its own registration wrote.

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 <noreply@anthropic.com>
@Programcode

Copy link
Copy Markdown
Owner Author

Round-3 fix 2167cad: new forward-only migration 20261014220000_push_release_by_registration.sql replaces release_push_token with (p_installation_id, p_registration): a release deletes only rows its own registration id wrote, plus the caller's own id-less rows from that install, never by token. The app marks a record released before sending the release, re-sends it before any other push change, and always registers with a fresh id. New pgTAP cases for the finding's ordering and the same-account variant fail on the old function and pass now (db gate 533 PASS); push.test.ts same-account case fails before, passes after. Local: npm run verify green (343 vitest, edge 380, smoke 30), typecheck, eslint, knip. Codex r4 (xhigh) next.

@Programcode

Copy link
Copy Markdown
Owner Author

Codex review r4 at 2167cad (gpt-6.1-sol, xhigh): no findings. Every ordering checked (late registration commit, delayed release across and within accounts, lost release response, token transfer, two installs, fresh ids), plus the upgrade from 20261014200000 (old 3-argument function dropped with its grants; definer auth, empty search path and registration-scoped locking kept). Merging into feat/native-ios. Nothing deployed; real push delivery needs a build with the push entitlement and an APNs key from Adam's Apple Developer account.

@Programcode
Programcode merged commit 036b76b into feat/native-ios Oct 14, 2026
8 of 19 checks passed
@Programcode
Programcode deleted the native-ios/07b-push branch October 14, 2026 23:03

This branch was successfully deployed

2 active deployments
Preview – debate-navigator — 2167cad7 Deployed Oct 14, 2026 by vercel[bot]
Preview – debatelab-staging — 2167cad7 Deployed Oct 14, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants