An iOS app for keeping track of what you cooked, whether the kids ate it, and what to cook next. Supabase is the source of truth: dishes, meals, household members and verdicts live in Postgres, photos in a private storage bucket. One person can cook and invite others to rate it.
Sign-in is an emailed six-digit code β no password, and signing in for the first time creates the account.
Open apps/ios/NomNom.xcodeproj in Xcode and run. Deployment target iOS 17. A debug build
talks to a local Supabase; see Running it.
Earlier versions kept everything on-device with SwiftData and no account. That layer is gone β the app now requires a login and a network connection, and there is no offline mode.
RankingCoreis the only part that came across untouched.
Log β the diary. Photo (camera or library), a title, a date, and a verdict per kid: π loved / π ok / π nope. Leaving a verdict blank means "didn't catch it", which is ignored by the ranking rather than counted as a bad score.
Calendar β a month grid where each day shows the food's photo, with a coloured dot per meal for how the day went. Tap a day to see its meals, or add one straight onto that date.
What to eat β a ranked list of what to cook next, with a filter sheet.
Inbox β meals other people asked you to rate, answerable with one tap from the list, plus a record of who rated your cooking.
The debug build points at a local Supabase, because that is the only place the sign-in code can actually be read: the local stack captures outgoing mail in Mailpit instead of sending it. The hosted project's built-in SMTP is rate limited to a couple of messages an hour and only to authorised addresses.
cd apps/supabase && npx supabase start # start local Docker stack
cd - && ./scripts/seed.sh # applies pending migrations & seeds 40 recipes, 58 meals, 5 parties
# Or: ./scripts/seed.sh --reset (full DB reset + migrate + seed)Then βR. Services/Supabase/SupabaseConfig.swift picks the environment with #if DEBUG, so a
release build goes to the hosted project instead. (Note: database seeding is strictly local-only and must never be run against production).
The shared scheme passes -dev-sign-in cook@foodlog.test, so a debug build walks
straight past the sign-in screen into the Log tab. Nothing to type, and nothing to
do after a db reset clears the account out from under the keychain.
To see the real form instead, untick that argument in Product βΈ Scheme βΈ Edit
Scheme βΈ Run βΈ Arguments. Then sign in with any address β you@example.com will
do β and read the code out of Mailpit at http://127.0.0.1:54324.
Two more arguments sit alongside it, unticked: -seed-sample-data loads a few
months of made-up meals and -initial-tab 2 opens What to eat, which together
give the ranking something to show. The same flags work from the command line:
xcrun simctl launch booted se.joelsanden.nomnom \
-dev-sign-in cook@foodlog.test -seed-sample-data -initial-tab 2-dev-selfcheck additionally runs
the store's self check.
DevSignIn takes the short way when it can. The CLI bakes a fixed service_role
key into every local stack, so it creates cook@foodlog.test if it isn't there β
setting a known password and marking the address confirmed β and signs in with
grant_type=password. One round trip, no mail, no six digits, and it behaves the
same whether the account already existed. If any of that fails it falls back to the
flow a person actually uses: ask for a code, read it back out of Mailpit, submit it.
The password path exists only here. The app itself has no password login and no UI that could reach one; the account is reachable by password because a debug build asked a local admin API to make it so.
Both routes check the host first and refuse anything but loopback, so this cannot be pointed at the hosted project even by accident, and the whole file β service key included β is compiled out of release builds.
Save a view file and the running app picks up the change in about a second, without relaunching and without losing where you were β still signed in, still on the same tab, same scroll position.
Two packages do it. InjectionLite is
the engine: it watches for saved sources, recompiles the one file, and rebinds its
symbols in the running process. Inject
is the SwiftUI half β @ObserveInjection listens for the swap and
.enableInjection() re-renders. Both are plain SPM dependencies; there is no
menu-bar app to install or remember to start.
Nothing to run. βR, edit, save:
π [SuggestionsView.swift] Recompiling
β‘ Compiled in 818ms
β
Hot reload complete - Rebound 5 symbols
What reloads: the body of any function, which in practice means view bodies β
layout, colours, strings, conditions, the scoring weights in RankingCore.
What needs a normal βR: anything that changes the shape of a type rather than
its behaviour. Adding or removing a stored property, a new @State, a new case on
an enum, a new file, a changed function signature. If a save seems to do nothing,
that's the reason β rebuild and carry on.
Every top-level view in a file carries the two Inject lines, so every file is covered; sub-views in the same file come along with it.
All three are on the Debug configuration only, and project.pbxproj is the only
place they live:
OTHER_LDFLAGS = -Xlinker -interposableβ without it the symbols are bound directly and cannot be swapped.SWIFT_COMPILATION_MODE = singlefileβ injection recompiles one file, so whole-module is no good. Release stays onwholemodule.EMIT_FRONTEND_COMMAND_LINES = YESβ Xcode 16.3 stopped logging the compiler invocations that InjectionLite reads to know how to recompile a file.
There is also a launch argument in the scheme,
-HotReloadingBuildLogsDir $(BUILD_DIR)/../../Logs/Build/current.xcactivitylog.
InjectionLite finds the build log by watching one land while the app is already
running, which never happens on a plain Run β so the path is handed over up front
instead, as a UserDefaults key read out of NSArgumentDomain. The filename is
ignored; only the directory matters, and the newest log in it is the one scanned. If
the console says β οΈ Logs dir not initialised, that argument is what to look at.
InjectionLite guards its whole implementation behind #if DEBUG, and the linker
flag is Debug-only. Checked rather than assumed β a release build has no
OTHER_LDFLAGS, and the binary contains zero occurrences of InjectionLite,
xcactivitylog, or the watcher's startup string.
The thing that makes the rest work is that "Tacos" cooked in March and "tacos"
cooked in June are the same dish. Three mechanisms, in
DishRepository.swift and
String+Matching.swift:
- Ranked autofill under the title field β exact match, then whole-name prefix, then word prefix ("kΓΆtt" β "KΓΆttbullar"), then substring, then near-typo. Ties break towards dishes you cook often and recently.
- Inline ghost completion β the rest of the best prefix match appears greyed out; tap the arrow to accept it.
- "Did you mean β¦?" β if what you typed is one or two edits from an existing dish, the app offers to use the existing name instead. This is the safety net for the case where you skipped the suggestions and typed "Pancaks".
Matching is done on a folded key (lowercased, accents stripped, punctuation
collapsed), so KΓΆttbullar & mos and kottbullar mos land on the same dish.
The client's find-or-create is the friendly path, not the guarantee: that is
unique (owner_id, normalized_name) in the database. Two devices logging "Tacos" at
once both miss locally and both insert, and the loser gets a 23505 β which
FoodStore treats as "go and read the row that won" rather than an error.
The maths lives in RankingCore.swift,
which is deliberately free of SwiftData and SwiftUI so it can be reasoned about on
its own. SuggestionEngine.swift
adapts the models onto it, applies the filters, and writes the explanation chips.
Each dish gets five numbers:
| Term | Meaning |
|---|---|
| like | Recency-weighted mean verdict, 0β1. Each verdict decays with a 180-day half-life, so a dish they hated 18 months ago isn't condemned forever. |
| readiness | 1 β exp(βdays / Ο), where Ο is the dish's own average gap between servings (clamped 4β60 days). A weekly staple feels overdue after a week; something you make twice a year does not. |
| exploration | 1 / β(1 + verdicts) β a UCB-flavoured bonus for dishes you know least about, so ratings get more reliable over time instead of the same five meals circling forever. |
| weekday | Share of servings that landed on today's weekday, so taco Friday surfaces on Fridays. |
| dislike | Ramps 0 β 1 as the least happy eater's score falls from "ok" to "nope". Subtracted, so one kid's hard no can sink an otherwise popular dish. |
A dish with no verdicts scores a neutral 0.5 on like rather than 0 β it isn't punished for being unknown. A dish never cooked gets readiness 0.7, not 1.0: without any history it isn't overdue, it's just available, and letting both readiness and exploration max out would double-count novelty.
Same five numbers, different weights β that's all a mode is.
| Mode | Leans on |
|---|---|
| Balanced | Liked food you haven't had lately, with a small nudge towards the barely-tried. |
| Favourites | Almost pure popularity; ignores exploration entirely and doubles the dislike penalty. |
| Overdue | Rotation first, even for merely-ok dishes. |
| Adventurous | The dishes you know least about. |
- Must be liked by β per kid, keeps only dishes whose recent verdicts from that person average out to a yes (β₯ 0.55).
- Not eaten for at least N days β a hard rotation floor on top of the soft readiness curve.
- Hide dishes somebody disliked (on by default) β drops anything where the worst verdict is below "ok"; catches both a named kid's no and an unnamed "nobody liked it".
- Include dishes we've never rated β off means known quantities only.
- Tags β free-form, entered on the meal (
quick,oven,veggie,fridayβ¦).
Every row carries chips saying why it's there ("Everyone's happy", "Overdue β 34 days", "Elsa yes, Vidar no", "Only tried once"), and Show scores in the section header reveals the raw number. Tapping a row breaks the score into its parts. A ranking you can't interrogate is one you stop trusting.
Monorepo (pnpm + Turborepo): apps/ios (this app), apps/web (Next.js site + /admin
dashboard), apps/supabase (migrations + Edge Functions), packages/* (shared lint/TS
config). Full folder-by-folder rules live in AGENTS.md. Shape, condensed:
apps/ios/NomNom/
App/ Root navigation & app lifecycle (RootTabView, RootView, NomNomApp)
Features/ Auth, Calendar, Diary, Insights, Notifications, Parties,
Recipes, Settings, Suggestions β each with Views/ + Components/
(+ Engine/ where there's non-UI logic, e.g. Suggestions, Recipes)
Core/ Components/ (AppButton, design-system primitives), Design/ (tokens),
Extensions/, Fonts/, Parsing/
Domain/ Plain models β Meal, Recipe, Party, Profile, Reaction, HealthIndex,
PostgresDate β no UI code
Services/ FoodStore/ (every read & write, split FoodStore+<Domain>.swift),
Supabase/ (SupabaseConfig, PhotoCache), Billing/ (RevenueCat)
Auth lives in Features/Auth (AuthController, Dev/DevSelfCheck); recipe-name
matching (DishRepository, String+Matching) lives in Features/Suggestions/Engine
and Core/Extensions respectively β see Keeping the names consistent.
Each row type has a matching insert or patch struct (NewMeal, MealPatch, β¦)
rather than being Encodable itself, because the database fills in id and
created_at and sending our own would fight the defaults.
FoodStore holds the whole visible dataset in memory. That fits the problem rather
than ducking it: the ranking needs every serving of every dish to work out a
rotation rhythm, so there is no useful partial load, and a household's few hundred
meals is nothing. One fetch on sign-in, then the arrays are patched as writes
succeed.
RLS decides what a plain select() returns, so those arrays hold my rows plus
the meals other people invited me to. The myDishes / myMeals accessors draw that
line for the views β the diary should show what I cooked, not somebody else's
dinner.
Downscaled to 1600px and JPEG-encoded, then uploaded to the private meal-photos
bucket as <meal_id>/<uuid>.jpg. That layout is load-bearing: the storage policies
read the meal id back out of the first path segment to decide who may see the
object.
A private bucket has no plain URL to hand to AsyncImage β every read is an
authenticated request β so PhotoCache downloads the bytes itself and keeps them in
memory and on disk. Without it, scrolling the calendar would re-fetch a photo for
every cell that came back on screen. Cache entries never need invalidating: a
replaced photo is written to a brand new path, so a path always means the same
bytes.
Two ordering details that are easy to get backwards:
- The photo is uploaded after the meal row exists. The storage policy checks the
meal id in the path against a real row in
meals, so before the first save there is nothing to upload to. - On delete, the object goes first. A row's cascade cannot reach into the bucket, so deleting the row first loses the only pointer to the object.
In debug builds, Log β π₯ β Fill with sample history loads a few months of made-up meals so the suggestion tab has something to chew on. It's idempotent.
Supabase installs default privileges in public that grant all of
INSERT/SELECT/UPDATE/DELETE to anon when the creating role is supabase_admin.
Locally the migrations run as postgres, whose default ACL grants anon only
Dxtm, so there is no SELECT and the difference never shows up. On the hosted
project it did: an unauthenticated request carrying only the publishable key
returned 200 [] for all eight tables. anon genuinely had SELECT, and RLS was
the only thing between that key and the data.
The baseline's final section takes it back, and also revokes it from the
role's default privileges so it can't quietly return on the next table. RLS is
supposed to be the second line, not the only one. anon is the pre-login role and
has nothing to do here β sign-up goes through GoTrue and handle_new_user is
security definer.
Verified against the deployed project: all eight tables return
401 permission denied, and an anon INSERT is refused at the privilege level
before RLS is even consulted. authenticated is untouched.
RankingCore.swift has no framework dependencies, so it can be exercised directly
against a frozen now β feed it a fixture of dishes and check the four modes order
them the way you'd expect. That's how the weights above were tuned: the first pass
put a never-cooked dish above the kids' favourites in Balanced mode, which is what
surfaced the readiness/exploration double-count.
- No offline mode. Every read and write goes to Postgres, so the app needs a
connection and shows an error without one. Nothing is queued or retried. Going
offline-capable means a local cache and a sync story, and the schema has no
support for the hard part β no
updated_atondishesormeals, no tombstones, so there is nothing to resolve a conflict with. - No test target in the project file.
RankingCoreis written to be testable and the RLS suite runs against the API, but there is no XCUITest target, so no automated test taps a single button. See what is and isn't verified. - Sign in with Apple is still unwritten. No longer blocked β the Developer
Program membership exists (team
D4F66LSYSF) β but it is App Store Guideline 4.8 work rather than anything this backend needs, and 4.8 may not even apply, since an emailed code is first-party rather than social login. - No push notifications yet. The Edge Function is written and signs an ES256
APNs JWT, but it is not deployed, and the app has no push code at all: no
entitlement, no capability, no registration, and nothing writes to
device_tokens. Both halves are in progress. - The app icon is an empty placeholder.
One person logs a meal, invites others, and each invitee gets asked to rate it.
The backend lives in supabase/; the client is the Inbox tab plus Ask someone to
rate this in the meal editor.
Invites are always by email address, because that is the only identifier a client
has: profiles deliberately holds no address and no client role may read
auth.users. The database resolves it β see
linking an invite to an existing account.
npx supabase@latest link --project-ref bctbqsrsmkyputxyiyzh
npx supabase@latest db push
npx supabase@latest functions deploy notify-invitees
npx supabase@latest functions deploy delete-accountThen in the dashboard add a Database Webhook on INSERT into
public.notifications pointing at the notify-invitees function. It must carry
WEBHOOK_SECRET, which the function checks before doing anything.
None of this is done yet on the new project β see Status.
The email template has to carry {{ .Token }}. GoTrue's stock magic-link email
contains only {{ .ConfirmationURL }} β a hashed link token β so an app that asks
for a six-digit code has nothing to type, and sign-in simply cannot be completed.
The code and the link are the same verification underneath; which one reaches the
user is decided entirely by the template.
Locally this is committed: apps/supabase/templates/ plus the
[auth.email.template.magic_link] and .confirmation blocks in config.toml. Both
are overridden because magic_link is what an address with an account receives and
confirmation covers a brand new one.
On the hosted project it is not, and the CLI does not push auth templates. Paste the same body into Authentication β Email Templates β Magic Link and Confirm signup:
https://supabase.com/dashboard/project/bctbqsrsmkyputxyiyzh/auth/templates
Verifying uses type: email, which GoTrue accepts for both a fresh signup and an
existing account, so the client never has to know which case it is in.
| Table | Purpose |
|---|---|
profiles |
Public face of an auth user β display name, emoji. Created automatically by a trigger on auth.users. |
dishes |
Canonical dish name per owner. unique (owner_id, normalized_name) is what stops "Tacos" and "tacos" splitting in two. |
meals |
One occasion of eating a dish. |
eaters |
Household members with no account β the kids. |
meal_invites |
Who was asked to rate a meal. invitee_id is null until that person signs up. |
meal_ratings |
One verdict per rater per meal, reaction 0β2 matching the client's Reaction enum. |
notifications |
The inbox, and the trigger source for push. |
device_tokens |
APNs tokens. Unused until there's an Apple Developer account. |
Two design points worth knowing:
Ratings have two possible sources. A verdict comes either from an invited
account holder (rater_id) or from a household member the cook rated on their
behalf (eater_id), enforced by a check constraint that exactly one is set.
Without the eaters table, moving storage to Postgres would have quietly dropped
the app's original feature, since only invited adults have an auth.users row.
You can invite an email that has no account. The invite is stored with
invitee_email and no invitee_id; a trigger on auth.users claims it on first
sign-in, which then fires the notification. Two partial unique indexes rather than
one constraint, because nulls don't collide in a unique index.
Both were found by building the client against it, and both were invisible to the original 21 RLS checks β which is the interesting part: the tests covered what the code did, not what the feature needed.
Now folded into the baseline.
handle_new_user claims pending invites, but only on INSERT into auth.users β only
when the invited address signs up after being invited. Nothing handled the opposite
and more common order: inviting somebody who already has an account. That invite kept
invitee_id = null forever, and everything downstream keys off it:
notify_inviteereturns early, so no notification is ever created,meal_invites_select(invitee_id = auth.uid()) never matches, so the invitee cannot see the invite,is_meal_participantis false, so they cannot read the meal or the dish,meal_ratings_insertrefuses their rating with42501.
The invite was accepted by the database and then silently did nothing. Once two people use the app, that is the normal case.
The client cannot fix this: there is no email β uuid lookup available to it, by
design. So a BEFORE INSERT trigger resolves the address instead. A trigger rather
than an RPC keeps it off the API surface, so this adds no account-enumeration
oracle β the caller learns nothing it did not already supply.
Now folded into the baseline.
notify_rating guarded with if v_creator is null or v_creator = new.rater_id. But
rater_id is null for a household member's verdict, and uuid = null is null, not
true, so the guard never fired on that path. Every verdict a cook recorded for
their own kid filed a "Someone rated Tacos" notification in the cook's own inbox.
Seeding a few months of history produced 42 of them, and the badge said 42 on a
brand-new account.
The right question is not "is the rater the cook" but "is the person who recorded
this the cook", which for an eater rating is the household member's owner β
meal_ratings_insert only permits an eater rating from eaters.owner_id = auth.uid(), so the owner is by construction whoever entered it.
Comparing owners also keeps the case the old code was reaching for: a guest at the
table may record their own child's verdict on the cook's meal, and the cook should
still hear about that one. It also lets the notification name the child, which the
old version could not β it looked the display name up by rater_id, null here, so an
eater rating could only ever come out as "Someone".
Every table is RLS deny-by-default. Two things that are easy to get wrong and are both covered:
- GRANTs are a separate layer from RLS (the baseline's section 7). A policy
says which rows; a GRANT says whether the role may touch the table at all.
Without the grants every request fails with
42501however correct the policies are.notificationsdeliberately has noINSERTgrant β only theSECURITY DEFINERtriggers write it, so nobody can forge an entry in someone else's inbox. SECURITY DEFINERhelpers break RLS recursion, but they'reSTABLE. A policy onmealsthat queriesmeal_invites, whose policy queriesmeals, recurses forever; running the lookup as definer stops that. But a STABLE function sees the snapshot from the start of the statement, so duringINSERT ... RETURNINGit cannot see the new row. The select policies therefore check the owning column directly before falling back to the helper βowner_id = auth.uid() or public.can_read_dish(id). Without that first branch every create from the app fails, because supabase-swift's.insert().select()usesPrefer: return=representation.
This app originally shared a Supabase project with an earlier, differently-shaped nom-nom β one built around groups, meal indicators and AI-generated suggestions. Reusing it cost more than it saved, and the schema you see is shaped by that.
Six stray tables were dropped early on: users, groups, group_members,
invite_codes, ratings and, awkwardly, meals. The old meals had
(id, name, created_by, created_at) and none of dish_id, eaten_on, notes or
photo_path. The schema migration originally said create table if not exists,
which would have quietly kept that table and then failed on every insert and
foreign key downstream.
That was the first instance of a pattern worth naming, because it kept recurring: a statement that assumes it is creating an object rather than asserting the state that object must be in is a silent no-op wherever the object already exists. It looks correct, it runs without error, and it does nothing.
The one that actually drew blood was the photo bucket. insert into storage.buckets β¦ on conflict (id) do nothing met a public meal-photos bucket left behind by
the old app, hit the conflict, did nothing β and left every meal photo world-readable
behind a schema that believed it had made them private. storage is not a schema
this app owns, so nothing in our own cleanup could reach it.
An audit of all nine original migrations found thirteen instances of the pattern.
Rather than patch them one by one against a project nobody could fully inventory, a
new Supabase project was created and the migrations squashed into
20260827000000_baseline.sql. Re-pointing the app cost two lines in
Services/Supabase/SupabaseConfig.swift; there was no data to move.
So the baseline is plain β a fresh project has no previous tenant to work around β but two habits survived, each because it cost a real bug:
- The bucket asserts its settings with
on conflict do update, naming every column the app depends on. Not justpublic: a pre-existingfile_size_limitorallowed_mime_typeswould reject the client's 1600px JPEGs at runtime with no migration ever complaining. - Privileges are revoked before they are granted. "We deliberately withheld
INSERTonnotifications" is only true if the role did not already have it.
apps/supabase/maintenance/ still holds inventory scripts written for the old project.
They have not been re-verified against the new one and one of them carries a stale
keep-list, so treat them as historical rather than as tools.
apps/supabase/tests/rls_test.py drives the REST API with real user JWTs β no
service-role shortcuts except to create the test users β and asserts the negative
cases, not just the happy path:
cd apps/supabase && npx supabase start && npx supabase db reset
python3 tests/rls_test.py33 checks, covering: an outsider can read neither the meal nor the dish and cannot rate it; a guest cannot rate as somebody else, nor on behalf of the cook's kid; a rating cannot claim both sources at once; signing up claims a pending email invite and produces exactly one notification.
Twelve of those are new, and guard the two fixes above:
- inviting an address that already has an account links on insert, notifies once, and lets that person read and rate the meal;
- inviting your own address notifies nobody;
- recording your own kid's verdict notifies nobody;
- a guest recording their own kid's verdict does notify the cook, and names the child rather than saying "Someone".
The writes that matter most are only reachable by tapping β inviting somebody, rating
a meal you were invited to, replacing and then removing a photo β and a headless
simulator has no way to tap. DevSelfCheck calls the same FoodStore methods those
buttons call and logs a result for each:
xcrun simctl launch booted se.joelsanden.nomnom \
-dev-sign-in cook@foodlog.test -seed-sample-data -dev-selfcheck
xcrun simctl spawn booted log show --last 2m --info \
--predicate 'subsystem == "NomNom"' --style compact--info matters: the passes are logged at info level, which log show omits without
it.
17 checks: save a meal with a photo and read the photo back out of the private bucket; invite an existing account and confirm it resolved; edit a verdict and get an update rather than a duplicate; rate a meal you were invited to and see the invite marked answered; mark the inbox read; remove a photo and confirm both the column and the storage object are cleared; delete the meal.
It covers the store and its round trip through PostgREST and Storage. It says nothing about whether the buttons are wired to those methods.
Two of those assertions had to be written carefully, and the reason is worth
knowing if you extend them. Asking PhotoCache whether a deleted photo is gone
races the live list behind the screen, which renders the new meal and re-populates
the cache. Asking Storage for it with a GET is answered out of URLSession's
URLCache from the earlier successful fetch. Only list β a POST, so uncached β
actually reports the bucket's state.
Verified end-to-end against the local stack, through the app:
- the email-code sign-in, including account creation on first use;
- all four tabs rendering real data pulled over PostgREST;
- writes: 10 dishes, 21 meals, 42 verdicts and 9 photos created by app code, checked in Postgres afterwards;
- photo upload, authenticated download, and removal from the bucket;
- reading another account's meal through RLS, and the invite that grants it.
Not verified: that tapping each control invokes the right method. There is no XCUITest target, and driving the simulator's UI needs an accessibility permission this environment does not have, so the buttons were checked by reading the code and by screenshotting each tab, not by pressing them.
The repository is a pnpm/Turborepo workspace; apps/web/ is a Next.js 15 app providing
the landing page, App Store Review Guideline 5.1.1(v) compliant Privacy Policy,
interactive previews matching the iOS Stone & Pine design system, and the /admin
dashboard (recipe/health-score moderation, generation logs).
pnpm install # from the repo root β installs all workspace apps
cd apps/web
pnpm devRun type checking and production builds:
pnpm exec tsc --noEmit
pnpm buildWhen importing this repository into Vercel:
- Framework Preset: Next.js (auto-detected)
- Root Directory:
apps/web - Ignored Build Step:
apps/web/vercel.jsonconfigures"ignoreCommand": "git diff --quiet HEAD^ HEAD ./"so Vercel automatically skips deployments when commits only touch iOS code (apps/ios/), database migrations, or root documentation.
Supabase does not deliver APNs push itself. It provides Database Webhooks, Edge
Functions and Realtime; delivery is your call to make. notify-invitees signs an
ES256 APNs JWT and posts to Apple directly, dropping dead tokens on a 410.
It reads APNS_KEY_ID, APNS_TEAM_ID, APNS_BUNDLE_ID and APNS_PRIVATE_KEY, and
returns {"skipped":"apns-not-configured"} if any is missing β a no-op rather than an
error, so the webhook doesn't retry and the in-app inbox works on its own.
The Developer Program membership needed for the APNs key now exists (team
D4F66LSYSF), so this is unblocked. What is missing is the key itself, the secrets,
and the entire client half: the app has no entitlement, no Push Notifications
capability, no registration call, and nothing writing to device_tokens. Note that
push cannot be tested in the simulator β simctl push fakes a payload but exercises
neither APNs nor the function.
The database is rebuilt but not yet deployed. The nine original migrations are
squashed into 20260827000000_baseline.sql, which applies clean from scratch and
passes all 33 RLS checks locally. It has not been pushed anywhere.
The app points at a new Supabase project, bctbqsrsmkyputxyiyzh, created to
replace the one shared with an earlier project β see
why. That project is currently empty: no schema, no
Edge Functions, no email configuration.
Done: the iOS client. supabase-swift 2.55.1 through SPM, the SwiftData layer gone,
and auth, all four tabs, the inbox and the invite/rating flow running against
Postgres locally. Hot reload and a local sign-in shortcut for development.
Outstanding, roughly in order β tracked as issue #1 and its children:
- Rotate the API keys leaked by the old project's Edge Function, and retire it.
- Push the baseline to the new project.
- Custom SMTP via Resend, and the auth email templates β
which must carry
{{ .Token }}, or sign-in cannot be completed at all. - Deploy
notify-inviteesanddelete-account, and wire the webhook. - Verify hosted sign-in with a real emailed code, for two different people.
- Drive a Release build against hosted end to end. It has no sign-in shortcut β
DevSignIncompiles out entirely β so this exercises the real thing. - Push notifications, both halves.