Architektúra Übrig: začať v Berne, rozšíriť bez prerábania
Výskum a plán, 25. 9. 2026. Podložené aktuálnou schémou (db/001_schema.sql), aplikáciou (app/index.html) a 55 stránkami dokumentácie Supabase, ktoré boli v tejto session čitateľné cez nástroj vyhľadávania v dokumentácii. Všetko ostatné je označené experience, verify a zoradené v zozname na doverenie.
Odporúčanie v jednej vete: jeden Supabase projekt pre Švajčiarsko, jedna zdieľaná schéma, a na každom riadku dva stĺpce – org_id (bezpečnostná hranica) a city_id (prevádzková hranica) – s hierarchiou mesto → organizácia → miesto → členstvo; pravidlá (teploty, alergény, lehoty) v tabuľke rulesets verzovanej podľa krajiny a mesta, nie v kóde; protokol odovzdania ako nemenný záznam udalostí s hash-reťazou; frontend ostáva bez build kroku, ale prejde na ES moduly, PWA a push notifikácie; druhé mesto sa pridá riadkom v tabuľke, druhá krajina riadkom v rulesets a rozhodnutím o regióne – nikdy prepisom.
offers_update_kitchen pripína len reserved_by, takže kuchyňa môže priamym UPDATE nastaviť status='picked' alebo 'expired' a obísť RPC; (2) profiles_select_counterpart volá vnorený EXISTS na tabuľku s vlastným RLS – vzor, ktorý dokumentácia Supabase odporúča nahradiť SECURITY DEFINER helperom; (3) helpery my_role() / i_am_approved() nie sú v politikách obalené (select …), čo Advisor lint 0003 označí a pri raste dát stojí výkon. Oprava je migrácia 002_hardening.sql vo Fáze 0 (aditívna, bez zmeny aplikácie). Nespúšťam ju, kým nepovieš – dnes sa nič nestavia.Obsah
- Desať rozhodnutí
- Model nájomcov
- Pravidlá podľa jurisdikcie
- Protokol ako záznam udalostí
- Frontend, PWA, notifikácie
- Platforma, región, plán
- Náklady
- Plán vo fázach
- Čo doplniť s odblokovanou sieťou
- Technická príloha (EN, úplné poznámky)
1 · Desať rozhodnutí
| # | Rozhodnutie | Prečo | Čo zamietame |
|---|---|---|---|
| 1 | Jeden projekt, zdieľaná schéma, tenant stĺpce | Jediný vzor, ktorý dokumentácia Supabase podporuje a benchmarkuje; používateľ aktívny v dvoch mestách je jeden účet. | Schéma na mesto, projekt na mesto (násobí fixné náklady, láme krížové štatistiky). |
| 2 | Hierarchia mesto → organizácia → miesto → členstvo | Reštauračná skupina má viac kuchýň, NGO viac výdajní; dnes je používateľ = profil = kuchyňa, čo neškáluje. | „Osobný“ režim – každý koná za organizáciu (jednotlivec = org druhu individual). |
| 3 | Pravidlá v tabuľke rulesets, verzované, s platnosťou od–do, riešené mesto → krajina → global | CH 65 °C vs. UK 63 °C vs. US 60 °C; EU-14 = CH-14, US má 9 alergénov. Snapshot ruleset_id na každej udalosti povie v roku 2027, čo platilo. | Prahy a zoznam alergénov v kóde (dnes allergens <@ 1..14, lang in (…)). |
| 4 | Protokol odovzdania = append-only handover_events s hash-reťazou a JSON-schema validáciou | Obe strany sú Lebensmittelbetrieb; stav ponuky sa odvodzuje z udalostí, záznam sa nedá tichým UPDATE zmeniť. | Stav ako jediná pravda v offers.status. |
| 5 | RLS cez dva helpery v schéme private obalené (select …), index na každom tenant stĺpci | Dokumentácia meria 2–24 ms na 1 mil. riadkov s indexom oproti timeoutom bez obalenia. | Vnorené RLS podotázky v politikách (dnes profiles_select_counterpart). |
| 6 | Frontend bez build kroku, ES moduly + import map, JSDoc + tsc --noEmit na generovaných typoch | Jeden vývojár, ~10 pohľadov; typová kontrola názvov stĺpcov zadarmo (pravidlo 9: nikdy nepísať názov, ktorý si nečítal). | React/Tailwind/bundler; framework až pri > 15 previazaných reaktívnych stavoch. |
| 7 | Realtime cez Broadcast z DB na privátnych kanáloch city:<id>, Web Push cez Edge Function pre zavretú stránku | postgres_changes robí jednu RLS kontrolu na odberateľa a zmenu a je jednovláknový – dokumentácia ho pri škále neodporúča. | Nefiltrované postgres_changes (dnes). |
| 8 | Fronta pgmq + pg_cron + Edge Function dispatch pre e-mail, push, SMS, WhatsApp | Odosielanie oddelené od transakcie, opakovanie a dead-letter zadarmo v Postgrese. | Vystavenie pgmq_public prehliadaču (tabuľky front nemajú RLS). |
| 9 | Pro plán v deň, keď má prvý cudzí používateľ dostať magic link; región rozhodnúť pred prvým skutočným protokolom (Zürich eu-central-2 odporúčaný pre live, Paríž ostáva staging) | Predvolený SMTP doručuje len členom tímu; Free pauzuje po 7 dňoch; región sa nedá zmeniť na mieste. | PITR, log drains, read replicas pre pilot (až pri > 4 GB alebo požiadavke regulátora). |
| 10 | Druhé mesto = riadok v cities (téma, moderátori, manifest), druhá krajina = riadok v rulesets + rozhodnutie o projekte podľa právneho regiónu | Test architektúry: onboarding mesta cez UI s nulou commitov. | Fork kódu na mesto/krajinu. |
2 · Model nájomcov
Migrácia z dnešného stavu bez výpadku (dual-write): 1) aditívna migrácia vytvorí cities (seed bern), organisations, sites, memberships, city_roles a pridá nullable org_id/site_id/city_id na offers; 2) backfill v tej istej transakcii – jedna org na existujúci profil, jedno členstvo owner, jedno miesto na kuchyňu, admini → city_roles(bern, moderator); 3) trigger offers_fill_tenant doplní tenant stĺpce, keď starý klient vloží len kitchen_id – stará aplikácia beží ďalej; 4) nové politiky žijú popri starých (permisívne politiky sa spájajú OR), pgTAP dokáže, že oba tvary vidia to isté, potom sa staré zahodia; 5) feature flag cities.features->>'orgs_ui' zapne nové UI; 6) až nakoniec DESTRUKTÍVNE set not null a zahodenie profiles.org/address – s tvojím výslovným áno.
3 · Pravidlá podľa jurisdikcie
Tabuľka rulesets(scope, country, city_id, version, effective_from, effective_to, rules jsonb, source) + allergen_lists(code, version, items). Resolver private.active_ruleset(city, at) zlúči global ← krajina ← mesto. Klient si pri štarte zavolá app_bootstrap(city_slug) (anon, bez PII) a dostane {city, ruleset, allergens, locales, theme, features} – sedem otázok Freigabe-Checku sa renderuje z pravidiel. Príklad pre CH:
{ "hot_min_c": 65, "cold_max_c": 5, "cool_down_max_minutes": 120, "reheat_core_min_c": 72,
"allergen_list": "EU14", "max_hours_after_made": 24, "require_temp_at_handover": true,
"require_receiver_signature": true, "label_fields": ["dish","made_at","use_by","allergens","kitchen"] }
Pozor: výskum právneho rámca našiel dve sady prahov – BLV-Spendenleitfaden 2021 (≥ 60 °C, < 10 °C do 2 h) a GVG/HyV prax (65 °C / 5 °C). Ktorá platí pre odovzdanie, rozhodne telefonát s Kantonales Laboratorium (zoznam na doverenie, položka A1) – a zapíše sa do rulesets.source, nie do kódu.
4 · Protokol ako záznam udalostí
handover_events(offer_id, org_id, city_id, event_type, actor_id, actor_org_id, occurred_at, payload jsonb, ruleset_id, prev_hash, hash); typy published, reserved, released, handed_over, received, temp_checked, cancelled, expired, disputed, note. UPDATE/DELETE odobraté rolám a blokované triggerom; payload validovaný pg_jsonschema podľa (event_type, version). Podpisy a fotky v privátnom Storage buckete protocols s RLS cez storage.foldername. Odovzdanie je „úplné“, až keď kuchyňa zapíše handed_over a odberateľ received – obe strany si vedia dokázať, čo prešlo. Export CSV/PDF pre inšpektora, mesto a fundraising.
Audit v troch vrstvách: obchodný (handover_events), správcovský (audit_log trigger na org/členstvá/pravidlá) a pgaudit object-mode na dvoch citlivých tabuľkách. Log drains až na žiadosť regulátora.
5 · Frontend, PWA, notifikácie
- Moduly bez bundlera:
js/main.js,sb.js,i18n.js,views/*; supabase-js pripnutý (SRI) alebo vendorovaný doapp/vendor/pre offline. - i18n: zdroj de-CH, JSON na jazyk, ~40 riadkov vlastného
t()sIntl.PluralRules; časové pásmo vždy zcities.timezone; RTL pripravenosť cez logické CSS vlastnosti; coverage meter v pre-commite. - PWA: manifest na mesto (Edge Function), service worker: precache shell + slovník, network-first pre bootstrap, nikdy cache autentifikovaných dát; offline zápis len pre protokol (IndexedDB fronta, idempotentné
client_event_id). - Live zoznam: trigger na
offers→realtime.broadcast_changes('city:'||city_id), privátny kanál, jedna politika narealtime.messages; replay 3 dni. - Zavretá stránka: Web Push (VAPID) z Edge Function spustenej Database Webhookom; na iOS len pre PWA pridanú na plochu – overiť aktuálny stav.
- Testy: pgTAP na každú politiku (pozitívny + negatívny), Playwright proti lokálnemu stacku, kontraktový test „Freigabe-Check má N otázok z rulesetu“.
6 · Platforma, región, plán
| Otázka | Odpoveď | Zdroj / status |
|---|---|---|
| Kedy Pro? | V deň, keď má prvý ne-tímový používateľ dostať magic link (default SMTP doručuje len tímu; Free pauzuje po 7 dňoch; bez záloh na stiahnutie). | docs Custom SMTP, Production checklist |
| Región | Paríž (EU) je pre CH pilot právne OK (adekvátnosť); Zürich eu-central-2 existuje a pre mestá je „Daten in der Schweiz“ predajný argument. Odporúčanie: Pro projekt v Zürichu pri go-live, Paríž ako staging. Edge Functions nemajú Zürich región (beh najbližšie k používateľovi). | docs GDPR, PrivateLink, Regional invocations · verify zmena regiónu na mieste |
| Prihlásenie | Prejsť z magic linku na e-mail OTP kód (link z mailu otvára iný prehliadač než nainštalovaná PWA); shouldCreateUser:false + pozvánky. | docs Passwordless |
| Plánované úlohy | pg_cron každých 5 min expiruje ponuky a emituje udalosť; nočne retencia a štatistiky; Edge Functions cez pg_net + Vault. | docs Cron, Schedule functions |
| Správy | pgmq fronta, dispatcher každú minútu; e-mail + push pri štarte, SMS len pre zrušenie tesne pred vyzdvihnutím, WhatsApp len ak ho odberatelia už používajú (Business API – overiť ceny a schvaľovanie šablón). | docs PGMQ · verify WhatsApp |
| Identita | Roly v tabuľkách (okamžité odobratie), nie v JWT; výnimka: platform_admin claim cez Access Token Hook pre UI. Pozvánky s jednorazovým tokenom. Overenie UID cez Zefix. MFA povinné pre moderátorov mesta. SSO pre mestá až na žiadosť (Pro+). | docs RBAC, Hooks, MFA, SSO |
| Bezpečnosť | 13-bodový RLS checklist (v prílohe §10) s pgTAP testom, ktorý prejde pg_policies a padne na neobalenom auth.uid(). Telefón kuchyne viditeľný len protistrane rezervovanej ponuky (dnes každému schválenému odberateľovi). | docs RLS |
| Ochrana údajov | revDSG: Datenschutzerklärung pri prvom prihlásení, jednostranový register spracovaní, DPA so Supabase; retencia: ponuky 30 d → anonymizácia, 24 mes. → zmazanie; udalosti bez PII navždy (hash-reťaz). Nikdy alergie ľudí – len alergény jedla. | docs GDPR · verify lehota Selbstkontrolle |
7 · Náklady (odhad; USD podľa dokumentácie, CHF ≈ 0,85 × USD)
| Etapa | Plán a doplnky | USD/mes. | ≈ CHF/mes. |
|---|---|---|---|
| Bern pilot | Free, Nano, SMTP len pre tím | 0 | 0 |
| Bern live | Pro 25 + Micro 10 − 10 kredit + vlastná doména 10 + Resend free | ≈ 35 | ≈ 30 |
| 5 miest (~300 org, ~2 000 MAU) | Pro + Small 15 + doména + Resend ~20 (+ PITR 7 d 100 voliteľne) | ≈ 60 (160 s PITR) | ≈ 50–135 |
| 50 miest, 2 krajiny (~3 000 org, ~20 000 MAU, 2 projekty) | Pro/Team + Medium 60 + staging + 2 domény + PITR 14 d 200 + log drain 60 + egress + SMS/WhatsApp | ≈ 350–600 + správy | ≈ 300–500 + správy |
Kvóty z dokumentácie: MAU 50 k Free / 100 k Pro; egress 5 GB / 250 GB; DB 500 MB / 8 GB; Realtime 2 mil. správ a 200 spojení / 5 mil. a 500; Edge 500 k / 2 mil. volaní. Ceny sa menia – overiť na pricing stránke.
8 · Plán vo fázach
Konvencie pre každú fázu: číslované migrácie v db/ zrkadlené do supabase/migrations/; ku každej pgTAP test; rollback napísaný pred nasadením; „hotové“ = videné na skutočnom telefóne proti živému projektu. Nič z toho sa nestavia dnes – je to plán na tvoje schválenie.
Bern pilot – nič, čo rozbije
002_hardening.sql: obaliť helpery(select …), pripnúťstatusv update politike, nahradiť vnorený EXISTS helperom, tabuľkacitiess riadkom bern,offers.city_id,profilesvon z realtime publikácie- pgTAP: odberateľ nenastaví picked; anon nič nevidí
- Ops: rozhodnúť Zürich vs. Paríž; Pro + Resend ak sa hlási cudzí používateľ
- Exit: Advisor čistý, pgTAP zelený, jedna kuchyňa + jeden odberateľ dokončili rezerváciu na telefónoch
Organizácie, mestá, pravidlá
003_orgs.sql(tabuľky, backfill, trigger, nové politiky popri starých),004_rulesets.sql(seed CH + EU14, resolver,app_bootstrap),005_drop_legacy_policies.sqlpo dvoch týždňoch- App: ES moduly (bez zmeny správania), prahy z bootstrapu, org switcher za flagom, i18n do JSON
- Exit: každý profil má org + členstvo, nula prahov v kóde (grep guard), i18n 100 % de-CH
Protokol, notifikácie, PWA
006_events.sql,007_storage.sql,008_queue.sql(pgmq, push_subscriptions, cron),009_realtime.sql(broadcast)- Edge Functions:
dispatch,notify-offer,manifest - App: protokol z rulesetu, offline fronta, service worker, push opt-in
- Exit: úplné odovzdanie s teplotou a oboma potvrdeniami exportované a ukázané kontaktu z Lebensmittelkontrolle; push doručený na iOS PWA aj Androide
Druhé mesto, white-label, štatistiky
010_city2.sql– len INSERT, žiadna zmena schémy (to je test)011_stats.sql(api_v1pohľady, k-anonymita ≥ 5 org),012_webhooks.sql- App: rozlíšenie mesta (subdoména → param → default), tokeny témy pri štarte, manifest na mesto
- Exit: druhé mesto onboardované moderátorom cez UI s nulou commitov; štatistiky konzumuje externá strana
Druhá krajina
013_country.sql(rulesets AT/DE, de-AT overlay),014_legal_ids.sql- Rozhodnutie: ten istý projekt vs. nový podľa právneho regiónu (prenos dát, zmluva mesta, latencia)
- Exit: pilotná ponuka vo Viedni s AT pravidlami a formátmi bez forku kódu; právna kontrola AT Datenschutzerklärung
9 · Čo doplniť s odblokovanou sieťou
Tieto veci sa v session nedali otvoriť; sú aj v zozname na doverenie, sekcia C. Kým nie sú overené, čísla v tomto dokumente sú odhady.
- Supabase pricing (Pro/Team, spend cap, egress/GB, compute tabuľka) a stránka regiónov (Zürich
eu-central-2, medzery vo funkciách). - Realtime limits (kanály na spojenie, veľkosť správy, joins/s podľa plánu).
- Custom SMTP na Free pláne – povolené alebo len Pro.
- Web Push na iOS PWA – minimálna verzia, nutnosť inštalácie, akcie/odznaky (stav 2026).
- Zefix API (endpoint, registrácia, podmienky, kvóty) a špecifikácia kontrolného čísla UID (eCH-0097).
- WhatsApp Business Cloud API (schvaľovanie šablón, cena za konverzáciu v CH/AT), Twilio CH sender ID, Signal (bez oficiálneho API).
- revDSG: adekvátnosť EU (EDÖB), ohlasovanie incidentov, prah registra spracovaní; lehota uchovávania Selbstkontrolle (HyV) a AT ekvivalent.
- Supabase DPA a zoznam subprocesorov.
- PostgREST CSV výstup a limity veľkosti.
- Import maps a
Intl.RelativeTimeFormat– minimálne verzie Safari pre telefóny pilotu. - Retencia Postgres logov podľa plánu (rozhodnutie o pgaudit).
- Či sa dá zmeniť región projektu bez nového projektu.
10 · Technická príloha – úplné výskumné poznámky (EN)
*Written 2026-09-25. Grounded on the current repo (`/home/user/uebrig/db/001_schema.sql`, 164 lines; `app/index.html`, 43 844 B / 392 lines; `app/config.js`; `README.md`) and on 26 Supabase documentation pages retrieved via `search_docs`. Every number that comes from a doc is cited inline. Anything not covered by a doc is marked **[experience, verify]**. Prices are quoted in USD as the docs give them; CHF conversions are estimates.*
0. Where we stand (measured, not remembered)
- Schema: two tables (
profiles1:1 toauth.users,offerskeyed bykitchen_id → profiles.id), two enums (role_t= kitchen/taker/admin,offer_status_t), three SECURITY DEFINER RPCs withFOR UPDATErow-lock (reserve_offer,release_offer,mark_picked), helpersmy_role()/i_am_approved()moved to schemaprivateon 25.9. RLS on both tables; both tables in thesupabase_realtimepublication. - App: single file, supabase-js 2.86.0 UMD from jsDelivr,
signInWithOtpmagic link,postgres_changesonofferswith no filter → reloads board on any change, DE/FR/EN dictionaries in one objectI18N,langpersisted inlocalStorage,NotificationAPI only while the page is open. - Platform: Free plan,
eu-west-3(Paris), Nano compute. Project pauses after 7 days of low activity on Free (Production checklist, Project pausing); restorable for 90 days. - Things in the current schema that the roadmap below must fix (found while reading, not assumed):
offers_update_kitchenWITH CHECKonly pinsreserved_by; a kitchen can setstatus='picked'or'expired'directly, bypassing the RPC state machine. Addstatusto the check (onlyopen→cancelledallowed via direct update) or route cancel through an RPC too.profiles_select_counterpartruns a correlatedEXISTSonoffers— which itself has RLS — inside a policy onprofiles. Nested RLS is exactly the pattern the RLS performance guide says to replace with a SECURITY DEFINER helper (RLS performance, tip 4).- Policies call
public.i_am_approved()/public.my_role()bare, not as(select …). Lint0003_auth_rls_initplanwill flag this; wrapping caches the result per statement (Lint 0003). lang in ('de','fr','it','en')andallergens <@ array[1..14]are hard-coded jurisdiction facts — both move to the ruleset tables in §3.address texton both tables is the only location concept; §2 replaces it withsites.profilesin the realtime publication means every profile change fans out an RLS check per subscriber (Postgres Changes limitation, see §5). Harmless at Bern scale, remove before city 2.
1. Tenancy model
Recommendation
Three-level hierarchy, one Supabase project, shared schema, tenant columns on every row:
cities (Standort) 1─n organisations 1─n sites (Küche / Abgabestelle)
1─n memberships (user × org × role)
offers.site_id → sites; offers.org_id, offers.city_id denormalised (trigger-filled)
handover_events.offer_id → offers; + org_id, city_id denormalised
citiesis a table with config:id,slug(bern,zuerich,wien),country(ISO-3166-1),timezone(IANA),default_locale,locales text[],currency,ruleset_key(→ §3),theme jsonb(→ §9),features jsonb(feature flags per city),status(planned|pilot|live|closed),centre geography(Point),bbox.- Every business row carries both
org_idandcity_id.org_idis the security boundary (RLS),city_idis the operational boundary (moderation, stats, partitioning, white-label). Both are set by aBEFORE INSERTtrigger fromsite_id, never trusted from the client. - RLS authorisation goes through two SECURITY DEFINER helpers in
private, each wrapped in(select …)in the policy:
create function private.my_org_ids() returns uuid[]
language sql stable security definer set search_path = '' as $$
select coalesce(array_agg(org_id), '{}') from public.memberships
where user_id = (select auth.uid()) and status = 'active' $$;
create function private.my_city_roles() returns table(city_id uuid, role text)
language sql stable security definer set search_path = '' as $$
select city_id, role from public.city_roles where user_id = (select auth.uid()) $$;
-- policy shape used everywhere:
create policy offers_select_org on public.offers for select to authenticated
using ( org_id = any (array(select private.my_org_ids())) );
The = any(array(select fn())) form is the one the docs benchmark at 2–24 ms on a 1 M-row table with an index on the tenant column, versus timeouts without the wrap (RLS performance, section "Added example"). So: create index on offers (org_id), (city_id, status, pickup_to), (site_id).
- Client queries always add the filter the policy already implies (.eq('city_id', …)), because "policies are implicit where clauses" and the planner needs the explicit filter (RLS guide → Add filters).
- Policies always name to authenticated (or to anon explicitly for the public stats views) — stops anon evaluation before any function call (RLS guide → Specify roles).
Why not schema-per-tenant or project-per-city
- The docs do not describe schema-per-tenant as a Supabase pattern; the multi-tenant material that exists (SAML
sso_provider_idper tenant with restrictive RLS, pgTAP "complex organizations" example) is all shared-schema, tenant-column (SSO SAML → RLS, Advanced pgTAP). - Project-per-city multiplies fixed cost: "Each project you launch increases your monthly Compute costs"; compute credits cover one Micro/Nano project only (Compute usage). It also breaks cross-city users (an NGO active in Bern and Biel), cross-city stats, and shared rulesets.
- Project-per-country stays on the table for data-residency reasons only (§6); the schema must therefore never assume a single
country— it is a column oncities, and all config resolvescity → country → default.
Trade-offs
- Denormalised
city_id/org_idon child rows = redundancy guarded by triggers and a pgTAP test that assertsoffers.city_id = sites.city_idfor every row. Accepted: it keeps every policy a single-column predicate. - Array-of-orgs RLS degrades if a user belongs to thousands of orgs (docs: rethink above ~1 000–10 000 list items). Our users belong to 1–5 orgs. City moderators do not get orgs in the array; they get a separate
city_rolespredicate. - A user in company mode vs personal mode: no "personal" context exists in Übrig (unlike Sautero); every user acts for an org. Individuals (a private taker) get a one-person org of
kind='individual'. Keeps the model to one path.
Migration path from today's profiles/offers (zero-downtime, dual-write)
- Additive migration (002): create
cities(seedbern),organisations,sites,memberships,city_roles. Add nullableorg_id,site_id,city_idtooffers. Addorg_idtoprofiles(nullable). - Backfill in the same transaction: one org per existing profile (
name = profiles.org,kind = case role when 'kitchen' then 'kitchen' else 'taker' end,status = case approved when true then 'approved' else 'pending'), one membership (owner), one site per kitchen profile fromprofiles.address(geocode later,locationnullable at first),offers.site_id/org_id/city_idfrom the kitchen's org; existingadminprofiles →city_roles (bern, 'moderator'). - Compatibility layer: keep
kitchen_idandreserved_byas-is (they are still the user who acted — useful in the event log). Triggeroffers_fill_tenantderivesorg_id/site_id/city_idwhen a legacy client inserts with onlykitchen_id(from the user's single membership). Old app keeps working unchanged. - New policies live alongside old ones (permissive policies OR together). Add the org-based policies, run pgTAP proving old-shape and new-shape clients see identical rows, then drop the
kitchen_id = auth.uid()policies in migration 004. - Feature flag:
cities.features->>'orgs_ui'read once at boot via RPCapp_bootstrap(city_slug); the new UI (org switcher, site picker) renders only when true. Flip for Bern after pilot users are migrated. - Finally (005, DESTRUCTIVE, explicit approval):
set not nullonoffers.org_id/site_id/city_id, dropprofiles.org/address, keepprofiles.contact/phone/langas the person record.
Sources
RLS guide · RLS performance troubleshooting · Lint 0003 · Compute usage · Advanced pgTAP (multi-tenant example)
Verify later
- Run
explain analyzeunderset local role authenticatedonoffersafter migration 004 with a synthetic 100 k-row load (docs give the recipe) — before city 2, not after.
2. Data model additions
Recommendation
-- organisations
create table public.organisations (
id uuid primary key default gen_random_uuid(),
city_id uuid not null references public.cities(id), -- home city
kind text not null check (kind in ('kitchen','taker','both','individual')),
name text not null check (length(name) between 2 and 120),
legal_id text, -- CHE-123.456.789 (UID) / FN / SIREN…
legal_id_verified_at timestamptz, legal_id_source text, -- 'zefix' | 'manual'
status text not null default 'pending' check (status in ('pending','approved','blocked')),
created_at timestamptz not null default now()
);
create table public.sites (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references public.organisations(id) on delete cascade,
city_id uuid not null references public.cities(id),
name text not null, address_line text not null, postal_code text, locality text,
country char(2) not null,
location extensions.geography(Point, 4326), -- PostGIS
pickup_notes text check (length(pickup_notes) <= 300),
is_active boolean not null default true
);
create index sites_geo on public.sites using gist (location);
create table public.memberships (
user_id uuid not null references auth.users(id) on delete cascade,
org_id uuid not null references public.organisations(id) on delete cascade,
role text not null check (role in ('owner','staff')),
status text not null default 'active' check (status in ('invited','active','removed')),
primary key (user_id, org_id)
);
create table public.city_roles (
user_id uuid references auth.users(id) on delete cascade,
city_id uuid references public.cities(id) on delete cascade,
role text not null check (role in ('moderator','platform_admin')),
primary key (user_id, city_id)
);
PostGIS: enable into the extensions schema, store geography(Point), GIST index, insert as 'POINT(lon lat)' (longitude first), expose nearest-neighbour via an RPC using <-> — the docs' nearby_restaurants pattern verbatim (PostGIS). Note the doc's warning: PostGIS ≥ 2.3 is not relocatable between schemas — pick extensions on first enable.
Offers: add site_id, org_id, city_id, ruleset_id (snapshot of the ruleset in force when published, §3), replace allergens smallint[] check with a FK-like check against the ruleset's allergen list (trigger), keep kitchen_id/reserved_by as actor columns.
Handover protocol as append-only event log:
create table public.handover_events (
id bigint generated always as identity primary key,
offer_id uuid not null references public.offers(id),
org_id uuid not null, city_id uuid not null, -- trigger-filled
event_type text not null check (event_type in
('published','reserved','released','handed_over','received','temp_checked',
'cancelled','expired','disputed','note')),
actor_id uuid not null default auth.uid(),
actor_org_id uuid not null, -- whose behalf
occurred_at timestamptz not null default now(),
payload jsonb not null default '{}', -- temp_c, container_count, signature_ref, allergens_confirmed, photo_path
ruleset_id uuid not null, -- rules in force at that moment
prev_hash bytea, hash bytea not null, -- sha256(prev_hash || row) — tamper-evidence
constraint payload_valid check (extensions.jsonb_matches_schema(
'{"type":"object","properties":{"temp_c":{"type":"number"},"container_count":{"type":"integer","minimum":0}},"additionalProperties":true}', payload))
);
revoke update, delete, truncate on public.handover_events from authenticated, anon;
create trigger handover_events_immutable before update or delete on public.handover_events
for each row execute function private.raise_immutable();
- Inserts only via RPCs (
record_handover(p_offer, p_type, p_payload)), which also advanceoffers.status; the existing RPCs are rewritten to emit an event and derive status.offers.statusbecomes a cache of the last event — cheap to read, always reproducible. pg_jsonschemavalidates thepayloadperevent_type(the check constraint pattern is exactly what the docs show) (pg_jsonschema). Keep the schema in aevent_schemastable keyed by(event_type, version)and validate in the trigger so schemas can evolve withoutALTER TABLE.- Signatures: store a hash of the signature image + path in a private Storage bucket
protocols, folderorg_id/offer_id/…, RLS onstorage.objectsusing(storage.foldername(name))[1] = any(array(select private.my_org_ids()::text[]))(Storage access control, helper functions). Private buckets need signed URLs or a JWT download (Buckets). Both counterparties (kitchen org and taker org) need read → the taker's org gets a row inhandover_access(offer_id, org_id)written by thereservedevent, and the storage policy joins through it via aprivate.helper. - Both parties are food-business operators under CH law (README: "Bis zur Übergabe haftet der Betrieb") — the receiving side must be able to prove what it took over. Therefore the
receivedevent is written by the taker, thehanded_overby the kitchen; a handover is "complete" only with both. Export (§9) renders both.
Audit trail — three layers, each for a different question:
| Question | Tool | Notes |
|---|---|---|
| "What happened to this offer, legally?" | handover_events (own table) |
Business audit, exported to the parties, retained per §10 |
| "Who changed which row, including admins?" | own audit_log table filled by generic trigger on organisations, memberships, city_roles, rulesets (old/new jsonb, auth.uid(), current_setting('request.headers')::json->>'x-forwarded-for') |
Cheap, queryable, RLS-protected (moderators only) |
| "Which role ran which SQL against sensitive objects?" | pgaudit object logging via a dedicated no-login role granted select on profiles, handover_events |
Goes to Postgres logs, not a table; retention follows plan log retention (verify per-plan log retention) (PGAudit) |
| Long-term log archive | Log Drains (Pro+, $60/mo per drain + events) | Only when a funder/regulator asks; "public alpha" status (Log drain usage, Features status) |
Do not use pgaudit session mode with all — the docs warn about volume; object mode on two tables is enough.
Trade-offs
- Event-sourcing the offer state means two writes per transition; at ≤ 100 offers/day/city this is noise. The gain — a legally usable, hash-chained record — is the product.
handover_eventsgrows forever by design; §10 defines what is anonymised (payload PII, actor names) vs. kept (temperatures, times, counts).- PostGIS geography on Nano compute is fine for k-NN over hundreds of sites; heavy isochrone/routing stays out (the map research already uses OSM offline).
Sources
PostGIS · pg_jsonschema · Storage access control · Storage helpers · Buckets · PGAudit · Log drains · Postgres log config
Verify later
- Postgres log retention per plan (needed to decide whether pgaudit alone satisfies a cantonal inspector).
- Whether a hash-chained DB record is accepted as "Selbstkontrolle"-documentation by the Bern Kantonales Laboratorium — legal question, not technical.
3. Configuration per jurisdiction (rulesets)
Recommendation
Table-driven, versioned, effective-dated, resolved city → country → global:
create table public.rulesets (
id uuid primary key default gen_random_uuid(),
scope text not null check (scope in ('global','country','city')),
country char(2), city_id uuid references public.cities(id),
version int not null,
effective_from date not null, effective_to date,
rules jsonb not null, -- validated by pg_jsonschema against ruleset_schema v1
source text, note text, -- "LMG/HyV Art. …", link
created_by uuid, created_at timestamptz default now(),
check ((scope='country') = (country is not null) or scope='city'),
check ((scope='city') = (city_id is not null))
);
create unique index rulesets_active on public.rulesets (scope, coalesce(country,''), coalesce(city_id,'00000000-0000-0000-0000-000000000000'), version);
create table public.allergen_lists (
code text not null, -- 'EU14' | 'US9' | 'CH14'
version int not null,
items jsonb not null, -- [{"id":1,"key":"gluten","labels":{"de-CH":"Glutenhaltiges Getreide","fr-CH":"…"}}]
effective_from date not null, effective_to date,
primary key (code, version)
);
rules example (CH):
{ "hot_min_c": 65, "cold_max_c": 5, "cool_down_max_minutes": 120, "reheat_core_min_c": 72,
"allergen_list": "EU14", "max_hours_after_made": 24, "require_temp_at_handover": true,
"require_receiver_signature": true, "label_fields": ["dish","made_at","use_by","allergens","kitchen"] }
- Resolver
private.active_ruleset(p_city uuid, p_at timestamptz default now())merges global ← country ← city (jsonb ||, later wins) among rows whereeffective_from <= p_at::date and (effective_to is null or effective_to > p_at::date). Returnedidof the most specific row is whatoffers.ruleset_id/handover_events.ruleset_idsnapshot. - Client never hard-codes thresholds.
app_bootstrap(city_slug)(anon-callable, returns no PII) returns{city, ruleset, allergens, locales, theme, features}; the Freigabe-Check renders its seven questions fromrules. The existing README hygiene text ("heiss ≥ 65 °C oder innert 2 h auf ≤ 5 °C … ≥ 72 °C Kern") becomes the seed row forcountry='CH'. - Schema of
rulesis itself versioned (ruleset_schemas(version, schema jsonb)), checked in a trigger withjsonb_matches_schema— a new key (e.g. US "time as public health control" 4 h rule) is a new schema version, not a code change (pg_jsonschema). - Currency, date formats, first weekday come from
cities.default_locale+Intl, not from rulesets — they are presentation, not law. - Editing rulesets: moderators with
role='platform_admin'only; every change audited (§2 audit_log);effective_frommust be ≥ today + 1 for non-admins to stop retroactive rewriting of what was in force.
Why
EU-14 = CH-14 today, US has 9 (sesame added 2023), Austria adds nothing but different inspection wording; temperature thresholds differ (CH HyV 65 °C hot vs. EU-common 63 °C in UK, 60 °C in parts of the US). Snapshotting ruleset_id on the event is what makes a 2027 export say "at the time, the rule was X".
Trade-offs
- jsonb rules are less type-safe than columns; the pg_jsonschema check plus a pgTAP test per seed row compensates. Columns would force a migration per jurisdiction — the thing we are avoiding.
- Merge semantics (
||is shallow) — keeprulesflat by convention; the schema enforces flatness ("additionalProperties": false, no nested objects exceptlabel_fields).
Sources
Verify later
- Exact statutory thresholds per target jurisdiction (CH HyV Anhang; AT LMSVG; DE LMHV; US FDA Food Code 2022) — legal sources, unblocked network needed.
4. i18n architecture
Recommendation
- Source locale:
de-CH(ß-free, «guillemets»,Fr./CHF). Every key is authored in de-CH first;de-DE,de-ATare overlays containing only keys that differ (ß, «Jause», currency), resolved by fallback chainde-AT → de-CH → en. - One JSON file per locale,
app/i18n/de-CH.json,fr-CH.json,it-CH.json,en.json, loaded on demand withfetch()and cached by the service worker (§5). Keys are namespaced (board.reserve,check.q1,protocol.temp_label); values may contain ICU-lite placeholders{count}and plural blocks handled in ~40 lines of vanilla JS:
const pr = new Intl.PluralRules(locale);
function t(key, vars = {}) {
let s = dict[key] ?? fallback[key] ?? key;
if (typeof s === 'object') s = s[pr.select(vars.count)] ?? s.other; // {"one":"{count} Portion","other":"{count} Portionen"}
return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? '');
}
const fmtDT = new Intl.DateTimeFormat(locale, { timeZone: city.timezone, dateStyle: 'short', timeStyle: 'short' });
const fmtRel = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); // "in 2 Std."
const fmtNum = new Intl.NumberFormat(locale, { style: 'unit', unit: 'celsius' });
Intl.PluralRules, DateTimeFormat, RelativeTimeFormat, NumberFormat (unit style) and ListFormat are available in all evergreen browsers and iOS Safari; no library needed. experience, verify exact minimum iOS version for RelativeTimeFormat (Safari 14).
- Time zones come from cities.timezone, never from the device: a Vienna moderator looking at Bern must see Bern's pickup window. All DB timestamps are timestamptz; formatting is the only place a zone appears.
- Locale-bearing data in DB: allergen_lists.items[].labels and cities.name_i18n are jsonb maps keyed by BCP-47; the client picks labels[locale] ?? labels[base] ?? labels['de-CH']. User-generated text (dish names, notes) is not translated — it is shown as typed, with the offer's lang tag for lang= attributes.
- Translation workflow: scripts/i18n_extract.py scans app/**/*.js for t('…') and data-i18n and fails CI if a key is missing in de-CH.json; missing keys in other locales are listed, not fatal, until release. Machine translation: an Edge Function translate (Claude via the existing proxy pattern, or DeepL) writes candidates into translations_review(key, locale, mt_text, status); a reviewer accepts in a tiny admin view; accepted rows are exported back into the JSON files by script and committed — the repo, not the DB, is the source of truth for UI strings (DB holds only per-city overrides such as the city's own greeting).
- Coverage meter runs in pre-commit (lesson from Sautero: a meter that only runs at session close protects nothing).
- RTL readiness now, cheaply: <html lang dir> set at boot from locale; CSS uses logical properties only (margin-inline-start, padding-inline, inset-inline-end, text-align: start); no left/right in the stylesheet (a lint grep). Icons that imply direction (arrows) get [dir=rtl] & { transform: scaleX(-1) }. Nothing else is needed until an RTL locale is actually planned.
Trade-offs
- Hand-rolled ICU subset vs. a library (i18next, FormatJS): the subset covers plural + placeholders, which is 100 % of current strings. Ordinal/select/nested plural would justify FormatJS later; keep the JSON shape FormatJS-compatible (
{count, plural, one {…} other {…}}can be generated from our object form) so the swap is a build step, not a rewrite. - Lazy-loading dictionaries adds one request on first run; the service worker precaches the default locale of the city.
Sources
No Supabase doc applies; browser-platform knowledge experience, verify for Safari/iOS minimums.
5. Frontend architecture
Recommendation
Stay buildless. Leave the single file. Move to ES modules with an import map.
<script type="importmap">
{ "imports": {
"@supabase/supabase-js": "https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.86.0/+esm",
"app/": "./js/" } }
</script>
<script type="module" src="./js/main.js"></script>
- Module layout:
js/main.js(boot, router),js/sb.js(client, session),js/i18n.js,js/config.js(bootstrap payload),js/views/{board,mine,offer,protocol,admin}.js,js/lib/{dom,fmt,geo}.js,js/sw-register.js. No bundler, no TypeScript — but JSDoc +// @ts-check+tsc --noEmitin CI using types generated bysupabase gen types --lang typescriptgives compile-time column checking for free (Generating types). Rule 9 ("never write a name you have not read") becomes machine-enforced. - Pin the supabase-js version and add
integrity=SRI on the CDN import; or vendor the ESM build intoapp/vendor/to remove the CDN from the trust chain and the network-call check in the deploy workflow. [experience] vendoring is preferable for a PWA that must work offline. - PWA:
manifest.webmanifest(per-cityname/theme_colorserved via a tiny Edge Function or generated per city at build time — see §9), service worker with three strategies: precache app shell + default dictionary; network-first with cache fallback forapp_bootstrap; never cache authenticated data API responses (RLS-scoped, per-user). Offline reads = last board snapshot in IndexedDB rendered with an "offline since 12:03" banner. Offline writes = onlyhandover_events(the protocol is filled in cellars and loading bays): queue to IndexedDB, replay via Background Sync where available, else on nextonlineevent; events carry a client-generatedclient_event_id uuidunique index for idempotent replay. - Realtime → notifications, two tiers:
- In-app live list (page open): replace the unfiltered
postgres_changeswith Broadcast from the database — a trigger onofferscallsrealtime.broadcast_changes('city:' || new.city_id, …); clients joincity:<id>as a private channel; authorisation is one RLS policy onrealtime.messagescheckingrealtime.topic()against the user's cities. This is the docs' recommended method "for scalability and security", because Postgres Changes runs one RLS read per subscriber per change and is single-threaded (Subscribing to changes, Postgres Changes → Limitations, Realtime authorization). Broadcast Authorization is "public beta" (Features); messages are stored inrealtime.messagesfor 3 days and replayable (replay.since, max 25) — enough to catch up after a reconnect (Broadcast). Free quota: 2 M messages, 200 peak connections; Pro 5 M / 500 (Realtime pricing). - Page closed (the real need for takers): Web Push via an Edge Function.
push_subscriptions(user_id, org_id, city_id, endpoint, p256dh, auth, ua, created_at)with RLSuser_id = (select auth.uid()); a Database Webhook onoffersINSERT (apg_nettrigger, asynchronous) callsfunctions/v1/notify-offer, which selects subscribers in the samecity_idwithkind in ('taker','both'), filters by the org's preferences (veg/hot/pork radius), sends VAPID-signed pushes, deletes 404/410 endpoints (Database webhooks, pg_net). VAPID private key in Edge Function secrets. Edge Function limits: 256 MB, 2 s CPU, 150 s wall on Free / 400 s paid, ports 25/587 blocked (Limits) — fan-out of a few hundred pushes fits; beyond that, queue (§6). - Install prompt: capture
beforeinstallprompt, show a "Zum Home-Bildschirm" card after the second visit; on iOS show the Share → Add-to-Home-Screen hint, because Web Push on iOS works only for installed Home-Screen web apps (iOS 16.4+) [experience, verify current status]. - When to adopt a framework: never for this scope, unless (a) more than one regular front-end developer, or (b) a view needs > ~15 interdependent reactive states (e.g. a live dispatch map). Then Lit or Preact+HTM via import map — still no bundler. React/Tailwind is explicitly out (project rule).
- Testing:
- RLS/pgTAP:
supabase test dbon every PR againstsupabase startin GitHub Actions; every policy gets a positive and a negative test; tests setset local role authenticated; set local request.jwt.claims …(Testing overview, Testing your database). Migrations move intosupabase/migrations/sodb resetreplays them (CLI workflows). - E2E: Playwright against the local stack (Free) — Inbucket/Mailpit captures magic links locally; on Pro, against a preview branch created per PR, which has its own DB, auth, storage and functions (Working with branches). Branching is "beta" and Pro-only (Production checklist → Deployment).
- Contract: a JS test that loads
app_bootstrapJSON and asserts the Freigabe-Check renders N questions from the ruleset (guards against hard-coding creeping back).
Trade-offs
- Import maps need Safari ≥ 16.4 / Chrome ≥ 89 — acceptable for a 2027 launch; a fallback
<script nomodule>message suffices. - Web Push adds a subscription table with endpoints that are personal data (§10 retention: delete after 90 days without a successful delivery).
- Broadcast-from-DB requires
private: truechannels and Realtime Authorization; misconfiguring the public/private flag silently drops messages (docs note both sides must match).
Sources
Broadcast · Subscribing to database changes · Postgres Changes · Realtime authorization · Realtime pricing · Database webhooks · pg_net · Edge Function limits · Testing overview · Working with branches · Generating types
Verify later
- iOS Web Push for installed PWAs: current Safari version constraints, badge/actions support.
- Realtime per-plan limits page (
/docs/guides/realtime/limits) for channels-per-client and message size — the search returned it by reference only.
6. Backend / platform
Recommendation
One project for Switzerland now; one project per legal region later (EU/EEA can share; a non-adequate country gets its own). Decide the region of the CH project before Pro, because a project cannot change region in place (experience, verify — docs describe "Restore to a new project"/duplicate, not in-place region moves).
- Region: Paris (
eu-west-3) is inside the EU. For Swiss revDSG, transfers to the EU are to countries with adequate protection (FDPIC list) experience, verify, so Paris is legally fine for a Bern pilot. Supabase also offers Zurich (eu-central-2) — the GDPR page lists it in the "Europe" grouping and warns it is not EU (GDPR compliance); the PrivateLink page confirms the region code (PrivateLink). For a municipality-facing product, "Daten in der Schweiz" is a sales argument; for later EU cities Paris is the safer default. Proposal: create the Pro project in Zurich when going live in Bern (migrate data viadb dump/restore while the dataset is tiny), keep Paris as the staging project. If a second EU country comes, either serve it from Zurich (adequacy decision for CH exists) or open a Frankfurt/Paris project — the schema and app are region-agnostic by §1 (config.jsper deployment). Caveat: Edge Functions regional invocation list has no Zurich (Regional invocations) — functions run "closest to the user" by default, which is fine for notifications; DB-heavy functions would pay a Zurich↔Frankfurt hop. - When Pro becomes necessary — the day the first non-team user must receive a magic link. The default SMTP "will refuse to deliver messages to addresses that are not part of the project's team" and is rate-limited "not meant for production" (Custom SMTP). Custom SMTP (Resend/Postmark/SES) removes that; after enabling, the default is a low 30 emails/hour until raised in Rate Limits (Custom SMTP, Production checklist). Whether custom SMTP is configurable on Free is not stated in the pages retrieved — verify; regardless, Free pauses after 7 idle days, which a pilot cannot tolerate, and Free has no downloadable backups (Production checklist). Pro: $25/mo + compute (Micro ≈ $10, covered by the $10 credit; Nano is billed as Micro on paid plans and cannot be newly launched there) (Compute); 7 days of daily backups (Backups). PITR (7-day ≈ $100/mo, needs ≥ Small compute) is not needed for the pilot; the docs recommend it above 4 GB or when RPO < 24 h matters (Backups, Production checklist).
- Auth flow tweaks: switch magic link to email OTP code for the kitchen phone flow (link-in-email breaks when the mail app opens a different browser than the installed PWA) — same
signInWithOtp, template change only (Passwordless). UseshouldCreateUser: falseon the login form and a separate "join by invitation" path (§7). OTP validity ≤ 1 h per the checklist. Enable CAPTCHA on sign-up when abuse appears (docs list it as the most effective bot mitigation). - Scheduled work — all in Postgres, no external cron:
pg_cronevery 5 min:update offers set status='expired' where status in ('open','reserved') and pickup_to < now()plus emit anexpiredevent; nightly: retention jobs (§10), materialised stats refresh (§9). Sub-minute schedules are supported; job names are immutable (Cron quickstart).pg_cron+pg_net+ Vault to invoke Edge Functions on a schedule — the docs' exact recipe, secrets in Vault not in the job text (Scheduling Edge Functions, Vault).- Outbound messaging via
pgmq: queueoutbound_messages(durable "Basic" queue). Producers: DB triggers (offer published→ notify takers;reserved→ notify kitchen;handover_complete→ send both parties a PDF link). Consumer: Edge Functiondispatchinvoked bypg_cronevery minute (and immediately viapg_netfor latency-critical events), whichpgmq.read(queue, vt=60, qty=50)→ sends via channel adapter (Resend e-mail, Web Push, Twilio SMS, WhatsApp Cloud API, Signal viasignal-clion a tiny VPS [experience]) →pgmq.archiveon success, leaves for retry on failure;read_ct > 5→ dead-letter queue. Do not exposepgmq_publicto clients — queues stay server-side; the docs are explicit that pgmq tables have no RLS by default (PGMQ, Queues quickstart). Prefer channel preferences over channel sprawl: e-mail + Web Push at launch; SMS only for the "your reservation was cancelled 20 min before pickup" class; WhatsApp only if the pilot takers already coordinate there (README says they do — but Business API template approval and costs must be checked). - Rate limiting: Auth endpoints are covered by Supabase (OTP 360/h project-wide default, 60 s per user) (Rate limits). Data API has no per-user limiter → enforce business limits in RPCs:
check_rate('publish_offer', 20, interval '1 hour')against arate_events(user_id, action, at)table (indexed, pruned by cron). Edge Functions: per-IP token bucket in akv-style table or Upstash [experience]. - Abuse prevention: approval gate stays (org
status='pending'sees nothing; §1 policies includeorg.status='approved'via the helper);reports(reporter_org, target_type, target_id, reason, created_at)+ moderator queue;before-user-createdAuth Hook to block disposable-mail domains (Before user created hook).
Trade-offs
- Zurich vs Paris: Zurich wins on positioning, Paris on Edge-Function locality and EU expansion. Both work legally for CH. The cost of choosing wrong is one dump/restore while data is small — so decide before the first real protocol event is stored, not later.
- pgmq + Edge Function polling every minute = ~43 k invocations/month; Free includes 500 k, Pro 2 M (Billing quotas). Fine.
- Third-party channels are "your responsibility"; Supabase does not monitor them (Shared responsibility) — the dispatcher logs every attempt to
message_logso §8 can alert on failure rate.
Sources
GDPR compliance · PrivateLink (region list) · Regional invocations · Custom SMTP · Production checklist · Rate limits · Passwordless · Project pausing · Backups · Compute · Cron quickstart · Scheduling Edge Functions · Vault · PGMQ · Queues quickstart · Before user created hook · Shared responsibility
Verify later
- Regions page for the full current list and whether Zurich has any feature gaps (branching, read replicas).
- Custom SMTP availability on the Free plan.
- Resend/Postmark EU data-residency options; Twilio CH sender IDs; WhatsApp Business API template approval lead time and per-conversation pricing; Signal has no official business API.
7. Identity & access
Recommendation
- Roles live in tables, not in the JWT — with one exception.
memberships.role(owner|staff) andcity_roles.role(moderator|platform_admin) are read by theprivate.*helpers on every request, so a removed member loses access on the next query, not on the next token refresh. The docs warn thatauth.jwt()claims are "not always fresh" (RLS guide → auth.jwt()). The exception: a Custom Access Token Hook addsapp_metadata.platform_admin: truefor the handful of platform admins, so the client can render the admin UI without a round-trip; RLS still re-checks the table (Custom claims & RBAC, Custom access token hook). Never readuser_metadatain a policy (user-writable). - Permission matrix (pgTAP asserts every cell):
| Action | staff | owner | city moderator | platform admin |
|---|---|---|---|---|
| publish/cancel offer for own org's sites | ✓ | ✓ | – | – |
| reserve as taker org | ✓ | ✓ | – | – |
| write handover events for own side | ✓ | ✓ | – | – |
| manage sites, invite/remove members | – | ✓ | – | – |
| approve/block orgs in city | – | – | ✓ | ✓ |
| see protocols of any org in city | – | – | ✓ (read) | ✓ |
| edit rulesets, cities, themes | – | – | – | ✓ |
- Invite flow: owner creates
invitations(id, org_id, email_hash, role, token_hash, expires_at, accepted_by); the dispatcher (§6) mails a link?invite=<token>; the invitee signs in (OTP,shouldCreateUser: trueonly when a valid invite token is present — checked by RPCaccept_invitation(token)after sign-in, which comparessha256(token)and the session e-mail, inserts the membership, marks accepted). Tokens are one-time and 7-day. Uninvited sign-ups land in an org-less state and can only "create a new organisation" (→ pending approval). - Approval workflow:
organisations.statustransitionspending → approved | blockedby a city moderator via RPCset_org_status, which writesaudit_logand enqueues an e-mail. The Freigaben screen already exists; it becomes city-scoped. - Org verification (CH): field
legal_id= UIDCHE-###.###.###; client-side checksum (mod-11) [experience, verify algorithm]; Edge Functionverify-uidcalls the Zefix public API (search by UID → legal name, seat, status) and storeslegal_id_verified_at,zefix_name; moderator sees "Zefix: Restaurant X GmbH, Bern, aktiv" next to the self-declared name. Later countries: AT Firmenbuch, DE Handelsregister, FR SIRENE — same table,legal_id_sourcediffers. NGOs without UID: manual verification + document upload to the private bucket. - SSO for municipalities (later): SAML 2.0 is Pro+ and multi-tenant via
sso_provider_idin the JWT; maporganisations.sso_provider_idand use a restrictive policy so SSO users can only act inside their org (SSO SAML). Priced at $0.015 per SSO MAU above quota (Billing). Not before a municipality asks. - MFA: TOTP enrollment offered to owners and required (restrictive
aal2policy) forcity_roles— moderators can see PII across orgs (MFA).
Trade-offs
- Table-based roles cost one helper call per request (cached per statement); JWT roles are faster but stale. For a food-safety app where "removed staff must not see pickup addresses" matters, staleness loses.
- Zefix API is a hard external dependency; verification is asynchronous and never blocks approval — the moderator approves with or without it.
Sources
Custom claims & RBAC · Custom access token hook · SSO SAML · MFA · RLS guide
Verify later
- Zefix REST API: endpoint, auth (registration/API key), terms of use, rate limits.
- UID checksum specification (eCH-0097).
8. Observability & ops
Recommendation
- Logs: Supabase Logs Explorer (ClickHouse SQL since June 2026,
sourcecolumn per service; API/Postgres/Realtime logs) plus the MCPquery_logstool for the assistant (Query logs with SQL). Enablelog_min_duration_statement = 500msandauto_explainfor slow queries (Postgres log config). Check Security and Performance Advisors weekly (already available via MCPget_advisors). - App error tracking: an
client_errorstable is not the answer (PII in stack traces, unbounded growth). Use GlitchTip (self-hosted, EU) or Sentry with EU data residency andbeforeSendscrubbing of e-mail/phone/address; sample 100 % of errors, 0 % of performance traces [experience]. Edge Function errors go to Supabase function logs; the dispatcher additionally writesmessage_log(status, channel, error_code). - Uptime: an external monitor (UptimeRobot/Better Stack, EU) hits
GET /rest/v1/rpc/health(anon-callable, returns{db: ok, ruleset_version, city_count}) every 5 min and the static app URL; alert to phone. Realtime health via the Realtime report page (Realtime reports). - Backups: Pro daily backups (7 days) are the floor. Add a weekly off-site logical dump via GitHub Actions cron:
supabase db dump --linked | age -r <pubkey>→ private bucket in another provider; storage objects are not part of DB backups (Backups), so alsosupabase storage cp -rfor theprotocolsbucket (legal records). Restore drill once per quarter into a scratch project; the drill is a runbook, and the runbook has a date of last execution infacts.json. - Runbooks (one page each, in
docs/runbooks/): project paused (Free) / restore; magic-link mail not arriving (SMTP reputation, rate limits, link scanners — the docs describe the scanner problem and the redirect-page fix); Realtime silent (public/private flag mismatch,setAuth()missing); expired offers not expiring (cron job inactive —cron.job.active); dispatcher backlog (pgmq.metrics); rollback of a migration (branch delete/recreate,db reset --linkedonly on staging); key rotation (publishable key is public by design; rotate the secret key and Edge Function secrets); data-subject request (export/delete byuser_id). - Incident comms: status page (static, on the same GitHub Pages) + templated German notice (Sautero's
pilot-commspattern). Breach: revDSG requires notification to the FDPIC "as soon as possible" when high risk; keep the template ready [experience, verify wording]. - Cost model (estimates; USD list prices from docs, CHF ≈ 0.85 × USD — verify the pricing page):
| Stage | Plan & add-ons | USD/mo (list) | ≈ CHF/mo |
|---|---|---|---|
| Bern pilot (Free) | Free, Nano, default SMTP for team only | 0 | 0 |
| Bern live | Pro 25 + Micro compute 10 − 10 credits + custom domain 10 + Resend free tier | ≈ 35 | ≈ 30 |
| 5 cities (~300 orgs, ~2 000 MAU) | Pro 25 + Small 15 − 10 + custom domain 10 + PITR-7d 100 (optional) + Resend paid ~20 | ≈ 60 (160 with PITR) | ≈ 50–135 |
| 50 cities, 2 countries (~3 000 orgs, ~20 000 MAU, 2 projects) | Pro/Team + Medium 60 + Micro staging 10 − 10 + 2 domains 20 + PITR-14d 200 + log drain 60 + egress overage + SMS/WhatsApp usage | ≈ 350–600 + messaging | ≈ 300–500 + messaging |
Quota anchors from docs: MAU 50 k Free / 100 k Pro; egress 5 GB / 250 GB; DB 500 MB / 8 GB; Realtime 2 M msgs & 200 peak conns / 5 M & 500; Edge invocations 500 k / 2 M (Billing). Compute price table: Micro ≈ $10, Small ≈ $15, Medium ≈ $60, Large ≈ $111 (Compute); PITR 7/14/28 days ≈ $100/$200/$400 (PITR usage); custom domain ≈ $10 (Custom domain usage); log drain ≈ $60 + events (Log drain usage). Custom domain, PITR, log drains and compute are not covered by the Spend Cap.
Sources
Query logs with SQL · Postgres log config · Realtime reports · Backups · Billing · Compute · PITR usage · Custom domain usage · Log drain usage
Verify later
- Current pricing page (Team plan price, spend-cap behaviour, egress price per GB); FDPIC breach-notification wording.
9. Public API & integrations
Recommendation
- Read-only public stats per city: materialised view
stats.city_daily (city_id, day, offers_published, portions_offered, portions_handed_over, orgs_active, co2e_kg_est)refreshed nightly bypg_cron; exposed only through a versioned viewapi_v1.city_statswithsecurity_invoker = true(views created bypostgresbypass RLS otherwise — docs warn) andgrant select … to anon. Addapi_v1to the exposed schemas; never exposepublicaggregates that could de-anonymise a single kitchen (k-anonymity ≥ 5 orgs per bucket, enforced in the view). Endpoint = PostgREST:GET /rest/v1/city_stats?city=eq.bernwith the publishable key — no Edge Function needed. Cache headers via a Cloudflare Worker in front if load appears [experience]. - Open data: same view as CSV — PostgREST returns CSV with
Accept: text/csv; document it ashttps://api.uebrig.ch/rest/v1/city_stats?…once a custom domain exists (custom domains keep API URLs portable across projects and are a paid add-on (Custom domains)). Licence CC0 like the rest of the repo. - CSV export for municipalities/funders (authenticated): RPC
export_protocols(p_org uuid, p_from date, p_to date) returns text(moderators: any org in their city; owners: own org), streaming PostgREST CSV. Large exports → Edge Function writing a file into the private bucket and returning a signed URL (1 h). - Webhooks for partner tools:
webhook_endpoints(org_id|city_id, url, secret, events text[], active); the samepgmqoutbox (§6) carrieswebhook.delivermessages; the dispatcher signs with Standard Webhooks headers (webhook-id,webhook-timestamp,webhook-signatureHMAC-SHA256) — the same scheme Supabase uses for its own hooks — and retries with backoff; deliveries logged inwebhook_deliveries. Events:offer.published,offer.reserved,offer.handed_over,offer.cancelled,org.approved. Payload contains ids and non-PII summary; partners fetch details with their own JWT. - Inbound partner integration (TGTG-like): partner posts to Edge Function
partner/offerswith an API key stored hashed inapi_keys(org_id, key_hash, scopes); the function acts with the secret key but setsrequest.jwt.claimsto impersonate the org's service user before inserting, so RLS and triggers still apply [experience]. - API versioning: schema per major version (
api_v1,api_v2), views only, no tables. Breaking change = new schema, old one kept ≥ 12 months. Edge Functions carry the version in the path (/functions/v1/partner-v1-offers). - White-label per city:
cities.theme jsonb= design tokens ({"--brand":"#0A1A2F","--accent":"#34F7D7","--radius":"12px","logo":"cities/bern/logo.svg","name":"Übrig Bern"}) returned byapp_bootstrap(slug); boot appliesdocument.documentElement.style.setProperty()per token; logo from a public bucketbrand(public buckets are CDN-cached and skip RLS on read (Buckets)). City resolution order: subdomain (bern.uebrig.ch) →?city=→ user's default city → geolocation prompt. The manifest is served per city by a tiny Edge Function (/functions/v1/manifest?city=bern) so the installed PWA carries the city name and colour. Content the city may override: greeting, imprint contact, partner logos — stored incities.content_i18n jsonb, sanitised as text, never as HTML (singleescapeHtmlboundary, noinnerHTMLwith user data — project rule §11).
Trade-offs
- PostGIS views for a "map of all sites" as open data would expose kitchen locations; publish only city-level aggregates and taker organisations that opt in (
sites.public_listing boolean). - Version-per-schema means duplicate view definitions for a year; acceptable, and declarative schema files make the diff obvious (CLI workflows).
Sources
RLS guide → Views · Custom domains · Buckets · Working with branches (webhook payload uses Standard Webhooks)
Verify later
- PostgREST CSV output on the current Supabase PostgREST version; response size limits for
text/csvvia the Data API.
10. Security & compliance
Recommendation
RLS review checklist (run by rls-guard or by hand; each item = a pgTAP test where possible):
1. Every table in an exposed schema has RLS enabled (event trigger rls_auto_enable from the docs installed so new tables cannot forget it) (RLS guide).
2. Every policy names to authenticated or to anon; none is to public.
3. Every auth.uid()/auth.jwt()/private.* call is wrapped in (select …); Advisor lint 0003 is clean.
4. Every tenant column used in a policy is indexed.
5. No policy on table A subqueries table B under RLS — use a private. SECURITY DEFINER helper with set search_path = ''.
6. SECURITY DEFINER functions live in private (not exposed), have EXECUTE revoked from anon/public, and never take row data as a parameter that would defeat the initPlan cache.
7. UPDATE policies have both USING and WITH CHECK; WITH CHECK pins every column the client must not change (today's offers_update_kitchen fails this — §0).
8. Views are security_invoker = true or live in an unexposed schema.
9. Storage: private by default; every storage.objects policy pins bucket_id; listing vs. download separated with storage.allow_only_operation('object.list') where needed (Storage helpers).
10. Realtime: "Allow public access" off; realtime.messages policies check realtime.topic() against tenant membership (Realtime authorization).
11. pgmq, net, vault, cron, private, stats schemas are not in the exposed list; vault.decrypted_secrets has no grant to authenticated.
12. Negative tests: anon sees zero rows everywhere except api_v1; staff of org A cannot read org B; a removed member (status removed) loses access without token refresh; a pending org sees no open offers.
13. Generic guard: a pgTAP test that iterates pg_policies and fails on any policy whose qual contains auth.uid() not preceded by (select.
Secrets: the publishable key in config.js is public by design; it grants nothing beyond RLS (RLS guide → Bypassing). The secret key exists only in Edge Function secrets and GitHub Actions secrets; third-party API keys (Resend, Twilio, VAPID, Zefix) in Edge Function secrets; keys needed inside SQL (cron → function auth) in Vault (Vault). Enable SSL enforcement and Network Restrictions on the DB; MFA on the Supabase org with two owners (Production checklist).
PII minimisation:
- Phone numbers: move to contact_channels(org_id, kind, value, visible_after text) and reveal only to the counterparty of a reserved offer (today's profiles_select_counterpart reveals the kitchen's phone to every approved taker for every open offer — tighten to reserved-only, kitchens' pickup address stays visible since it is the offer).
- Person names: the protocol needs "who handed over" — store actor_id; render the name from profiles at read time; after retention, the join returns "ehemaliges Mitglied".
- Push endpoints, IP addresses in audit_log: 90-day retention.
- Photos of dishes: optional, no faces, EXIF stripped client-side [experience].
Retention (pg_cron nightly, each step idempotent and logged):
- offers: 30 days after pickup_to → note, address copy nulled (site remains), status kept; 24 months → row deleted, aggregate already in stats.city_daily.
- handover_events: payload PII (signature_ref, free text) removed after 24 months; temperatures/times/counts kept for the statutory self-control retention — CH practice is commonly cited as 2 years [experience, verify with cantonal lab]; the row itself is never deleted (hash chain).
- invitations 30 days after expiry; rate_events 7 days; message_log 90 days; auth.users without membership and without login for 12 months → deleted via admin API (their events keep actor_id as a dangling UUID, which is the point).
revDSG (CH, in force 1.9.2023): privacy notice at first sign-in (what, why, retention, Supabase as processor in FR/CH, Resend/Twilio as sub-processors, rights, FDPIC); a processing register (Verzeichnis der Bearbeitungstätigkeiten) — not mandatory below 250 employees unless high-risk, but a one-page register is cheap and funders ask; DPA with Supabase — Supabase provides one on request (GDPR compliance → DPA); Supabase is SOC 2 Type 2 (Security). Health data is not processed (allergens describe food, not people — keep it that way; never store a taker's allergies).
EU expansion notes: GDPR Art. 28 processor contract = the same Supabase DPA; data location Paris (EU) or Zurich (adequate third country, per the docs' own note that Zurich is not EU and needs a specific EU region if EU-only is required) (GDPR compliance); Art. 30 register becomes mandatory-in-practice; Art. 33 72-hour breach notice; cookie/consent: the app sets only functional storage (session, locale) — no consent banner needed, but state it in the notice [experience, verify per country].
Sources
RLS guide · Storage helpers · Realtime authorization · Vault · Production checklist · GDPR compliance · Security overview
Verify later
- Statutory retention for food-safety self-control records in CH (HyV) and AT; FDPIC guidance on processing registers for small entities; Supabase DPA text and sub-processor list.
11. Phased build plan
Conventions for every phase: migrations are numbered files in db/ and mirrored to supabase/migrations/ so supabase db reset replays them; every migration ships with supabase/tests/database/NNN_*.test.sql (pgTAP); "rollback" = the forward migration that undoes it, written before the change ships; nothing is "done" until seen on a real phone against the live project.
Phase 0 — now: Bern pilot, nothing that breaks
- Migrations:
002_hardening.sql(additive/compatible only): wrap helper calls in(select …); pinstatusinoffers_update_kitchenWITH CHECK(status in ('open','cancelled')); replaceprofiles_select_counterpart's correlated subquery withprivate.can_see_profile(uuid); addcitiestable with one rowbernandoffers.city_iddefault → Bern (nullable, trigger-filled); removeprofilesfrom the realtime publication. - RLS tests: pgTAP: taker cannot set
pickedvia UPDATE; taker sees kitchen phone only for open/reserved-by-me offers; anon sees nothing. - App: none required; optional: switch to OTP code template; add
shouldCreateUserhandling. - Ops: custom SMTP (Resend) + Pro if any non-team user must log in; otherwise keep Free and a weekly
db dumpin CI; decide Zurich vs Paris (§6). - Rollback:
002_down.sqldrops the new policies and re-creates the old ones (kept verbatim in the file). - Exit: Advisor security/performance lints clean; pgTAP green in CI; one real kitchen and one real taker completed a reservation on their phones.
Phase 1 — org/membership + cities + rulesets, dual-write, feature flags
- Migrations:
003_orgs.sql(tables from §2 minus events; backfill;offers_fill_tenanttrigger; new org policies alongside old);004_rulesets.sql(rulesets, allergen_lists, seed CH + EU14,active_ruleset,app_bootstrap);005_drop_legacy_policies.sqlafter two weeks of both running. - RLS tests: matrix from §7 (every cell); dual-shape equivalence test (legacy insert with only
kitchen_idyields identical visibility to new-shape insert); ruleset resolver returns CH values for Bern at three dates. - App: ES-module split behind the same URL (Phase 1a, no behaviour change); read thresholds/allergens from
app_bootstrap; org switcher and site picker behindfeatures.orgs_ui; i18n moved to JSON files +t()with plurals. - Rollback: feature flag off restores the old UI instantly;
003_down.sqldrops the new tables (data loss only of orgs created after backfill — acceptable in a flag-off scenario); never drop legacy columns in this phase. - Exit: every Bern profile has an org, a membership and (kitchens) a geocoded site; zero hard-coded thresholds in
app/(grep guard in pre-commit for65,72,1,2,3,4,5,6,7,8,9,10,11,12,13,14); i18n coverage 100 % de-CH, ≥ 95 % fr/en.
Phase 2 — protocol event log + notifications + PWA
- Migrations:
006_events.sql(handover_events, immutability, hash chain, jsonb schemas, RPCs rewritten to emit events,offers.statusderived);007_storage.sql(privateprotocolsbucket + policies, publicbrand);008_queue.sql(pgmqqueue,push_subscriptions,message_log, cron jobs: expiry, dispatcher, retention);009_realtime.sql(broadcast trigger on offers,realtime.messagespolicy, publication emptied). - Edge Functions:
dispatch,notify-offer(or folded into dispatch),manifest. - RLS tests: events immutable (UPDATE/DELETE raise); taker org reads only its own offers' events; storage policy positive/negative; realtime policy: user in Bern cannot join
city:zuerich; queue schemas not exposed (testinformation_schemagrants). - App: protocol screen driven by ruleset (
require_temp_at_handover), offline queue with idempotent replay, service worker, install prompt, Web Push opt-in, broadcast channel replacespostgres_changes. - Rollback: Postgres Changes kept subscribable for one release (both paths coded); dispatcher can be paused via
cron.alter_job(active := false); events table stays (append-only, harmless). - Exit: a full handover with temperature + both-party confirmation exported as CSV/PDF and shown to the Bern pilot's food inspector contact; push notification received on an installed iOS PWA and on Android; Realtime peak connections and message counts observed in the report page under Free/Pro quota.
Phase 3 — second city, white-label, public stats
- Migrations:
010_city2.sql(insert city row, theme, moderators; no schema change — that is the test);011_stats.sql(statsschema, materialised views,api_v1views withsecurity_invoker, anon grants);012_webhooks.sql(endpoints, deliveries). - RLS tests: a Zürich moderator cannot approve a Bern org;
api_v1.city_statsnever returns a bucket with < 5 orgs; anon can readapi_v1only. - App: city resolution (subdomain → param → default), theme tokens at boot, per-city manifest; public stats page (static, fetches
api_v1). - Ops: Pro, custom domain
api.uebrig.ch, PITR decision, preview branches for PRs, uptime monitor, restore drill #1. - Rollback:
cities.status = 'closed'hides a city everywhere (policies includecity.status <> 'closed'); views can be dropped without touching tables. - Exit: second city onboarded by a moderator through the UI with zero commits; stats endpoint documented and consumed by at least one external party (municipality dashboard or journalist).
Phase 4 — second country: rulesets, locale, project/region decision
- Migrations:
013_country.sql(rulesetsscope='country'for AT/DE,allergen_listsunchanged for EU,citiesrows withcountry,de-AToverlay file);014_legal_ids.sql(legal_id_sourcevalues, verification function per country). - Decision: same project (Zurich/Paris) vs. new project per legal region — driven by (a) the target country's transfer rules toward the current region, (b) a municipal contract demanding in-country hosting, (c) latency. If a new project:
supabase db dump --schema-only+ migrations replay + seed rulesets; the app is pointed at it byconfig.jsper deployment; cross-project stats via theapi_v1endpoints, not DB links. - RLS tests: an AT moderator sees no CH data even with the same schema; ruleset resolver picks AT values for Vienna; de-AT overlay resolves to de-CH for missing keys.
- Exit: Vienna pilot offer published with AT rules and AT date/currency formatting, no code fork; legal review of the AT privacy notice done; DPA/Art. 28 covered.
Things to verify with unblocked network
- Supabase pricing page — Pro/Team fees, spend cap, egress per GB, current compute table; and regions page — full list incl.
eu-central-2Zurich and any feature gaps. - Realtime limits page (
/docs/guides/realtime/limits) — channels per connection, message size, joins/sec per plan. - Custom SMTP on Free plan — allowed or Pro-only.
- Web Push on iOS PWA — minimum iOS/Safari version, requirement to be installed to Home Screen, support for actions/badges (2026 state).
- Zefix API — public REST endpoint, registration, terms, quotas; UID checksum spec (eCH-0097).
- WhatsApp Business (Cloud API) terms — template approval, per-conversation pricing in CH/AT, opt-in wording; Twilio CH alphanumeric sender rules; Signal (no official API — confirm).
- revDSG specifics: FDPIC adequacy list (EU listed), breach-notification duty wording, processing-register threshold; CH HyV retention period for self-control records; AT LMSVG equivalents.
- Supabase DPA text and sub-processor list (needed for the privacy notice).
- PostgREST CSV support and size limits on the current Supabase version.
- Import maps /
Intl.RelativeTimeFormatminimum Safari versions for the pilot's phones. - Postgres log retention per plan (for the pgaudit decision).
- Whether project region can be changed without a new project (docs seen only describe restore/duplicate).
Scope declined
- Schema-per-tenant or project-per-city: rejected in §1 — cost, cross-tenant users, and no Supabase doc support for the pattern.
- A framework or bundler now: rejected in §5 — one developer, ~10 views; ES modules + import map +
tsc --noEmiton JSDoc give the maintainability without a build step. - Storing UI translations in the database: rejected in §4 — repo is the source of truth; only per-city content overrides live in
cities. - PITR, log drains, read replicas for the pilot: deferred to Phase 3/4 with explicit triggers (DB > 4 GB, regulator/funder demand, second region).
- Storing takers' personal allergies or any health data: declined permanently in §10 — the product needs allergens of food, not of people.
- Exposing
pgmq_publicto browsers: declined in §6 — queues stay server-side.