# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Code Language **All code must be written in English.** This applies to variable names, function names, type names, database column names, SQL identifiers, comments, and any other code artifact. The UI may display text in any language, but the code layer is always English. ## Project Status **Fase 0–5 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). Production stack deployed to ambrosio (OVH VPS) at https://erosi.oier.ovh (app + Supabase) + https://auth.oier.ovh (Keycloak); Let's Encrypt certs via host Caddy; PWA strategy switched to generateSW to unblock the prod build (see gotcha #16). Fase 7 complete: 12 new Playwright tests fill the collective-flow UI gap (O-01..O-03 onboarding, I-01..I-05 invitation acceptance, MC-02..MC-05 admin-manage) — writing them surfaced + fixed three latent bugs in the product code (atomic `create_collective()` RPC, pendingInvitationToken restore after login, manage page not reloading on late $currentCollective hydration). Plus migration 013 (auth.users role/aud default trigger) — found after the first real self-registration on prod via Keycloak→GoTrue left the user with empty `role`/`aud` columns, breaking every REST call with `role "" does not exist`. 255 tests green: 41 pgTAP + 140 Vitest integration + 15 Vitest unit + 58 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅** - `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings - `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules) - `plan/fase-*.md` — phase-by-phase implementation plans (Fase 0 through Fase 7; Fase 7 is complete — collective-flow E2E coverage) - `design/slate_collective/DESIGN.md` + `design/*/screen.png` — "Monolith Editorial" direction + per-screen Stitch mockups (desktop + mobile variants). Consult before changing UI. ### Fase 1 — what has been built - `supabase/migrations/001_users.sql` — `users` table, `handle_new_user` trigger - `supabase/migrations/002_collectives.sql` — `collectives`, `collective_members`, `collective_invitations`; auto-promote-oldest-admin trigger - `supabase/migrations/003_rls.sql` — full RLS policies, `is_active_member`/`is_member`/`is_admin` helpers, `accept_invitation()` function - `supabase/migrations/004_storage.sql` — `avatars` bucket (private, signed URLs) - `apps/web/src/lib/supabase.ts` — Supabase singleton with PKCE flow - `apps/web/src/lib/auth.ts` — `login()` / `logout()` via `supabase.auth.signInWithOAuth` - `apps/web/src/lib/stores/auth.ts` — `currentUser`, `authLoading`, `isAuthenticated`, `displayName` - `apps/web/src/lib/stores/collective.ts` — `currentCollective`, `userCollectives`, `collectiveMembers` - `apps/web/src/lib/components/Avatar.svelte` — initials / emoji / upload with deterministic colour hash - `apps/web/src/lib/components/ImageCropper.svelte` — lazy-loaded cropperjs, 1:1, 256×256 WebP output - `apps/web/src/routes/+layout.svelte` — auth state listener, collective bootstrap - `apps/web/src/routes/(app)/+layout.ts` — SSR disabled (`ssr: false`); no auth check here (see gotcha below) - `apps/web/src/routes/(app)/+layout.svelte` — sidebar with collective switcher; auth redirect via `$effect` - `apps/web/src/routes/auth/callback/+page.svelte` — PKCE code exchange - `apps/web/src/routes/onboarding/+page.svelte` — create collective or join via link - `apps/web/src/routes/(app)/collective/manage/+page.svelte` — members, roles, invite link generator - `apps/web/src/routes/invitation/[token]/+page.svelte` — accept invitation (auth-aware) - `apps/web/src/routes/(app)/settings/+page.svelte` — display name, avatar, language, sign out - `apps/web/messages/en.json` + `es.json` — all Fase 1 + 2a strings - `packages/types/src/database.ts` — manually seeded from migrations (update with `just db-types` once CLI is wired) - `setup.sh` — idempotent local dev setup (prerequisites, .env, Docker, migrations, seed, Paraglide) - `infra/kong-start.sh` — Kong container entrypoint; substitutes `${VAR}` placeholders in `kong.yml` before Kong starts (Kong 2.8 does not do this natively) ### Test suite — what has been built - `packages/test-utils/` — shared test infrastructure (Vitest integration tests) - `package.json` — `@colectivo/test-utils`; depends on `@supabase/supabase-js`, `jose`, `pg` - `vitest.config.ts` — loads root `.env` via `loadEnv`; single-fork mode (no parallelism races) - `src/seed-constants.ts` — fixed UUIDs for all 5 seed users + collective + seed list - `src/supabase-clients.ts` — `createClientAs(userId)` signs HS256 JWT with `SUPABASE_JWT_SECRET`; `createAdminClient()` uses service role key - `src/db-helpers.ts` — `sql()` via raw `pg` Pool for direct DB manipulation (bypasses RLS); used to set `deleted_at = 8 days ago` etc. - `tests/rls-isolation.test.ts` — F-series: Eva (non-member) sees zero rows, all writes blocked - `tests/rls-collective.test.ts` — B-series: collective read/update/invite by role - `tests/rls-lists.test.ts` — C-series: list CRUD, soft-delete, trash window, cross-collective isolation - `tests/rls-items.test.ts` — D-series: item CRUD by role, cross-collective item isolation - `tests/rls-frequency.test.ts` — E-series: frequency read-only + trigger populates on INSERT - `supabase/tests/*.sql` each start with `CREATE EXTENSION IF NOT EXISTS pgtap;` so they're idempotent on a fresh DB - `001_accept_invitation.sql` — `accept_invitation()` happy path + not_found / expired / already_used; uses `set_config('request.jwt.claim.sub', …)` to set `auth.uid()` for the test session, and looks up by `token` (not `id`) - `002_item_frequency_trigger.sql` — trigger normalizes name with `lower(trim(...))` and increments `use_count` on repeat INSERTs. RLS write-blocks are covered by the Vitest suite instead (postgres superuser bypasses RLS here) - `003_promote_on_admin_leave.sql` — deletes a synthetic admin from `public.users` to fire the `BEFORE DELETE` trigger and asserts the oldest remaining member is promoted - `apps/web/playwright.config.ts` — Playwright config; `globalSetup` is a Keycloak health check (not a session cache), `reuseExistingServer: true` - `apps/web/tests/fixtures/users.ts` — seed user constants (Ana, Borja, Carmen, David, Eva) - `apps/web/tests/fixtures/login.ts` — `loginAs(page, user)` runs the real OAuth flow (Keycloak → GoTrue PKCE) and waits for the session token to land in localStorage. Each test that needs auth calls this in `beforeEach`; cached `storageState` is NOT used (see gotcha #12) - `apps/web/tests/e2e/auth.test.ts` — A-series: unauthenticated redirect, full login, collective visible in sidebar, sign-out, guest login - `apps/web/tests/e2e/lists.test.ts` — C-series: create (with reload-persistence check), soft-delete → appears in trash drawer, archive, guest read-only - `apps/web/tests/e2e/items.test.ts` — D-series: seed list renders, add item (with reload-persistence check), toggle check, desktop-hover delete, frequency suggestions, guest read-only **Test commands:** ```bash just test-all # run every suite (pgTAP + Vitest + Playwright) — requires just dev running just test-integration # Vitest RLS tests (requires just dev stack running) just test-db # pgTAP SQL tests (requires just dev stack running) just test-e2e # Playwright E2E tests (requires just dev running) just test-e2e-headed # Playwright with visible browser (debugging) ``` ### Fase 7 — collective-flow E2E coverage (2026-04-14) 12 new Playwright tests + 1 DB fixtures helper + 1 migration. Writing the tests uncovered three product-code bugs which are fixed as part of this phase. **Tests (all green, included in `just test-e2e`):** - `apps/web/tests/e2e/onboarding.test.ts` — O-01..O-03: Eva auto-redirect to `/onboarding`, happy-path create, empty-name submit-disabled. - `apps/web/tests/e2e/invitation.test.ts` — I-01..I-05: admin generates token, logged-out-then-accept, logged-in one-click accept, expired + already-used error states. - `apps/web/tests/e2e/manage-collective.test.ts` — MC-02..MC-05: promote member → admin, demote back, remove member, generate pending invitation. - `apps/web/tests/fixtures/db.ts` — `resetEva()` / `seedExpiredInvitation()` / `seedUsedInvitation()` / `restoreSeedMembership()` / `countMembership()`. All via raw SQL (bypasses RLS) so they don't depend on `SUPABASE_SERVICE_ROLE_KEY` being in the Playwright process env. **Scope dropped from the plan:** - **O-04** (multi-collective via a "+ new collective" button in the sidebar): the affordance does not exist in the UI; adding it would be feature work, out of a test-only phase. - **MC-01** (rename collective): `/collective/manage` has no rename input; same reasoning. Both are flagged in `plan/fase-7-collective-flow-tests.md` as future UI follow-ups. **Product-code fixes discovered while writing these tests (all committed with this phase):** 1. **`supabase/migrations/012_create_collective_rpc.sql` — atomic `create_collective(name, emoji)` RPC.** Pre-existing onboarding flow did two separate REST calls (`INSERT collectives` chained with `.select().single()`, then `INSERT collective_members`). Both failed: the RETURNING on the first triggered the SELECT policy (`is_member(id)`) which the creator can't pass yet, and the second was gated by `is_admin(collective_id)` against a collective with zero admins. The RPC is `SECURITY DEFINER` and does both inserts atomically. `onboarding/+page.svelte` now calls `supabase.rpc('create_collective', {...})`. 2. **`routes/+layout.svelte` — resume pending invitation after login.** `invitation/[token]/+page.svelte` stashes the token in `sessionStorage.pendingInvitationToken` before triggering OAuth, but nothing read it back. Post-login redirect logic now checks sessionStorage and routes the user back to `/invitation/` instead of defaulting to `/onboarding` or `/lists`. 3. **`routes/(app)/collective/manage/+page.svelte` — reactively reload members.** `onMount` called `loadMembers()` once; if `$currentCollective` was still null at that point (race with the auth listener on a cold navigation), the query short-circuited and the list stayed empty. Now subscribed to `currentCollective` so `loadMembers` re-runs whenever the store resolves. 4. **`routes/invitation/[token]/+page.svelte` — wait for authLoading before deciding.** Racing with the auth listener's first emit caused the page to see `!$isAuthenticated` for a freshly-authenticated user and trigger a redundant Keycloak round-trip. Now awaits `authLoading → false` before branching. **Test IDs added to UI (stable selectors for Playwright):** - Onboarding: `onboarding-create-tab`, `onboarding-join-tab`, `collective-name-input`, `collective-submit`. - Manage: `member-row-`, `role-select-`, `remove-member-`, `generate-invite`, `invite-link`. - Invitation: `invitation-accept`, `invitation-error` (with `data-error-key` attribute carrying the error key for assertion). **Gotchas discovered during execution (do not remove):** 18. **`.select()` after `.insert()` evaluates the SELECT RLS policy on the returned row, not just the INSERT policy.** PostgREST's chained `.insert(...).select().single()` adds `RETURNING *` to the INSERT, and Postgres then evaluates the table's SELECT policy against the fresh row. For policies that gate on sibling-table state (e.g., `is_member(id)` requiring a row in `collective_members`), this causes INSERTs to spuriously fail with "new row violates row-level security policy" even though the INSERT policy itself passed. Fix: drop the `.select()` if you don't need the row back, or (better) wrap the insert + any sibling-table sets in a `SECURITY DEFINER` RPC that atomically builds the membership state. F-08 in `rls-isolation.test.ts` has been silently masking this since Fase 1 because it only asserts the _spoof_ case; never verified the happy-path insert actually succeeded. 19. **GoTrue v2.158.1 inserts OIDC-provisioned users with empty-string `role` and `aud`.** For users created via the external provider path (Keycloak → GoTrue), `auth.users.role` and `auth.users.aud` are left as `''` instead of the expected `'authenticated'`. The JWT minted from that row carries the empty role, and PostgREST's per-request `SET LOCAL role = $role_claim` then fails with `role "" does not exist` — every REST call errors out before policies or RPCs even run. Dev never surfaced this because `seed.sql` hardcodes both columns. Migration `013_auth_users_role_default.sql` backfills existing rows and installs a BEFORE INSERT trigger on `auth.users` that fills empty/NULL role + aud with `'authenticated'`. Users in existing browser sessions must log out + back in once for a fresh JWT after the migration is applied. 20. **`docker compose exec -T` without explicit stdin drains the surrounding heredoc.** When a script is delivered over SSH as `ssh host bash -s << 'REMOTE' ... REMOTE`, the outer bash reads its script from stdin. Any `docker compose exec -T ` inside that script — especially inside a command substitution like `x=$(docker compose exec -T db psql -c '...')` — inherits and consumes that same stdin, swallowing the rest of the heredoc. Symptom: the first iteration of a loop runs, every following iteration is silently skipped. Caused `infra/scripts/deploy-erosi.sh` to silently skip migrations 012 and 013 on prod until fixed (had to apply manually after every deploy). **Fix:** redirect stdin per call with ` 7d, purge deleted > 7d) - `supabase/migrations/006_item_frequency.sql` — `item_frequency` table, `fn_record_item_frequency` trigger (SECURITY DEFINER, fires on every `shopping_items` INSERT) - `apps/web/src/lib/stores/lists.ts` — shopping lists store (optimistic CRUD) + item operations + suggestion fetching - `apps/web/src/lib/components/ItemSuggestions.svelte` — frequency chips with active-prefix highlighting - `apps/web/src/routes/(app)/lists/+page.svelte` — overview: featured card + 2-col grid + completed section + trash view (sticky input replaced by masthead "New list" button in Fase 5.8) - `apps/web/src/routes/(app)/lists/[id]/+page.svelte` — list detail: items with checkbox, qty stepper (−/N/+), inline edit, swipe-to-reveal-delete (touch) + hover delete (desktop), drag & drop reorder via `svelte-dnd-action`, sticky add form with `ItemSuggestions` ## Local Dev: Required /etc/hosts Entry GoTrue (Supabase Auth) and the browser both must resolve `keycloak` to reach the Keycloak container. Add this line to `/etc/hosts` on the development machine: ``` 127.0.0.1 keycloak ``` Without this, the OAuth redirect URL (`http://keycloak:8080/...`) built by Keycloak's discovery document is unreachable from the browser. ## Planned Stack | Layer | Technology | |---|---| | Frontend / PWA | SvelteKit 2 + `@vite-pwa/sveltekit` + Workbox | | Auth / IdP | Keycloak self-hosted (OIDC/OAuth2) | | Backend / BaaS | Supabase self-hosted (GoTrue + Realtime + Storage + Kong) | | Database | PostgreSQL 15 (via Supabase) | | Offline storage | IndexedDB via `idb` | | Monorepo | Turborepo + pnpm workspaces | | Infra | Docker Compose (dev + prod) + nginx (prod, managed externally) | | CI/CD | GitHub Actions | ## Planned Repository Layout ``` apps/web/src/lib/ supabase.ts # Supabase singleton client (PKCE, GoTrue auth) auth.ts # login() / logout() via supabase.auth.signInWithOAuth (Keycloak provider) stores/ # Svelte global stores sync/ # offline queue + conflict resolution components/ # reusable UI components apps/web/src/routes/ # SvelteKit file-based routes packages/types/src/ database.ts # types generated from Supabase schema (never edit manually) domain.ts # domain types (Collective, ShoppingList, Item, etc.) supabase/ migrations/ # versioned SQL migrations seed.sql # dev test data (5 Keycloak users + sample collective) functions/ # Edge Functions (invitations, etc.) config.toml # Supabase CLI config + Keycloak OIDC keycloak/ realm-export.json # "colectivo" realm — imported on dev startup infra/ docker-compose.dev.yml docker-compose.prod.yml scripts/backup.sh / backup-cron.sh / restore.sh / deploy.sh ``` ## Development Commands (Justfile) ```bash just setup # first-time (and idempotent) local environment setup just dev # start docker-compose.dev.yml + SvelteKit dev server just serve # build + start prod Node server at :3000 against dev Docker stack just stop # stop all services (Docker stack + Vite dev server) just dev-stop # stop Docker stack only just dev-clean # stop Docker stack and remove all volumes (full reset) just db-reset # drop + migrate + seed (dev) just db-migrate # apply pending migrations (dev) just db-seed # apply supabase/seed.sql to the running dev db just db-types # regenerate TS types from Supabase schema → packages/types just kc-export # export Keycloak realm → keycloak/realm-export.json just kc-open # open Keycloak Admin Console (localhost:8080) just deploy # run infra/scripts/deploy.sh (prod) just backup supabase # immediate manual backup just restore supabase # restore a specific dump just logs # docker compose logs -f (prod) ``` ## Local Dev Endpoints and Credentials | Service | URL | Credentials (dev only) | |---|---|---| | SvelteKit app | http://localhost:5173 | — | | Supabase Studio | http://localhost:54323 | — | | Supabase API (Kong) | http://localhost:8001 | apikey: `PUBLIC_SUPABASE_ANON_KEY` from `.env` | | Keycloak Admin | http://localhost:8080/admin | `admin` / `admin` | | PostgreSQL | localhost:5432 | `postgres` / `postgres` | **Dev secrets (set in `.env`, never commit to prod):** | Variable | Dev value | |---|---| | `POSTGRES_PASSWORD` | `postgres` | | `SUPABASE_JWT_SECRET` | `super-secret-jwt-token-with-at-least-32-characters-long` | | `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` | `admin` / `admin` | | `KEYCLOAK_CLIENT_SECRET` | `gotrue-dev-secret` (client secret for `colectivo-web` in GoTrue) | ## Domain Model The central organizing unit is the **Collective** (household group), not the individual user. All content belongs to the Collective. **Roles:** `admin` (full CRUD + member management) | `member` (full CRUD on content) | `guest` (read-only) **Core entities:** `Collective` → `CollectiveMember`, `ShoppingList` → `ShoppingItem`, `TaskList` → `Task`, `Note`, `CollectiveInvitation` **Key field names (English):** `collective_id`, `list_id`, `is_checked`, `checked_by`, `checked_at`, `created_by`, `created_at`, `completed_at`, `sort_order`, `display_name`, `avatar_type`, `avatar_emoji`, `avatar_url`, `language` **Key business rules:** - Only one active shopping session per list at a time. - Completing a list keeps items as history; it can be "reset" (uncheck all → back to active). - Trash retains deleted items/notes for 7 days before permanent deletion. - If the sole admin deletes their account, the system auto-promotes the oldest member before allowing deletion. ## Auth Architecture (Keycloak → GoTrue → Supabase) **Strategy: Option B — GoTrue as OIDC proxy.** The app authenticates with Keycloak (PKCE), then exchanges the Keycloak token with GoTrue for a Supabase JWT. Supabase RLS uses `auth.uid()` which resolves to the GoTrue user UUID (mapped from Keycloak `sub`). GoTrue maintains `auth.users`. Key decisions: - **Self-registration enabled** in Keycloak — users can create accounts on the Keycloak login screen. - **Invitations are link-only** — no email sent. Admin generates a link and shares it manually. - **Multiple collectives per user** — a user can belong to several collectives and switch between them in the sidebar. Logout is a single call — `supabase.auth.signOut()` — which clears the GoTrue session. Keycloak's own SSO session persists (user won't be prompted for credentials on next login until the Keycloak session expires), which is acceptable for this app. **Non-obvious integration requirements (all implemented; do not remove):** 1. **`colectivo-web` is a confidential client** — GoTrue needs a client secret to exchange codes with Keycloak. `GOTRUE_EXTERNAL_KEYCLOAK_SECRET` must match Keycloak's client secret (`gotrue-dev-secret` in dev). 2. **Custom `openid` client scope in Keycloak** — GoTrue v2.158.1 sends `scope=profile email` (no `openid`) to Keycloak, ignoring `GOTRUE_EXTERNAL_KEYCLOAK_SCOPES`. Without `openid` in the token scope, Keycloak's userinfo endpoint returns 401. Fix: a custom client scope named `openid` with `include.in.token.scope=true` is set as a default scope on `colectivo-web`, so Keycloak injects `openid` even when not requested. 3. **`GOTRUE_DISABLE_SIGNUP: "false"`** — GoTrue's signup flag blocks OIDC-based user creation too. Must be `false`; Keycloak is the gatekeeper for who can register. 4. **`auth.users` pre-seeded with Keycloak UUIDs** — GoTrue generates its own UUIDs on first login. `seed.sql` pre-inserts both `auth.users` (with `instance_id='00000000-0000-0000-0000-000000000000'` and empty-string token fields) and `auth.identities` (provider=keycloak, provider_id=Keycloak sub). This ensures `auth.uid()` = seed UUID = FK in all public tables. 5. **Kong env var substitution via `kong-start.sh`** — Kong 2.8 does not interpolate `${VAR}` in declarative config files. `infra/kong-start.sh` runs `perl -pe 's/\$\{(\w+)\}/$ENV{$1}/ge'` on `kong.yml` before starting Kong. Without this, Kong loads the literal string `${SUPABASE_ANON_KEY}` as the API key, rejecting all requests. **SvelteKit + Supabase auth gotchas (do not remove these patterns):** 6. **Do not call `getSession()` in a SvelteKit `load` function to gate auth.** Supabase JS v2 initialises its session from localStorage asynchronously (`_recoverAndRefresh`). In a `load` function that runs immediately on mount, `getSession()` can return `null` for a valid session before initialisation completes — causing `login()` to fire and redirect to Keycloak unnecessarily. The correct pattern: `(app)/+layout.ts` only sets `ssr: false`, and `(app)/+layout.svelte` uses a `$effect` that redirects when `!$authLoading && !$isAuthenticated`. This waits for `onAuthStateChange` to fire (which is authoritative). 7. **`onAuthStateChange` fires `INITIAL_SESSION` on page load, not `SIGNED_IN`.** `SIGNED_IN` only fires immediately after a login flow. Load collective data whenever the session is present, regardless of event type — otherwise collectives are never loaded on page refresh. Pattern in root `+layout.svelte`: `if (session) { await loadUserCollectives(...) }` covering `INITIAL_SESSION`, `SIGNED_IN`, and `TOKEN_REFRESHED`. 9. **CSS custom properties use RGB triplets — always use `rgb()`, never `hsl()`.** All design tokens in `app.css` store values as space-separated RGB triplets (e.g. `51 65 85`). Using `hsl(var(--token))` interprets the first value as a hue degree — `hsl(51 65% 85%)` renders as light yellow, not slate-700. The Tailwind config and any `background-color`/`color` declarations in `app.css` must use `rgb(var(--token) / )`. 11. **Supabase JS v2 + SvelteKit: `loadUserCollectives` (or any PostgREST query) inside `onAuthStateChange` must be deferred with `setTimeout(..., 0)`.** GoTrue's default `navigator.locks`-based auth lock is held while the `onAuthStateChange` callback runs. Any `supabase.from(...).select(...)` called inside that callback calls `getAccessToken() → getSession() → initializePromise → _acquireLock`, which is the same lock that's already held by the emit logic — the promise never resolves, the query hangs forever. Fix: schedule the query off the callback's microtask chain with `setTimeout(async () => { await loadUserCollectives(session.user.id); … }, 0)`. As a defensive extra, `$lib/supabase` passes a pass-through `auth.lock` option so the whole lock path is a no-op. Both are needed: without the defer, some browsers still deadlock; without the pass-through, others do. Removing either one breaks the Playwright E2E suite. 12. **Playwright auth: do not cache Supabase sessions via `storageState`. Do a live Keycloak login per test.** `browser.newContext({ storageState })` restores localStorage + cookies, but Supabase's `_recoverAndRefresh` does not reliably re-fire `INITIAL_SESSION` from a restored context — the page hydrates without triggering `onAuthStateChange`, so `currentUser` / `currentCollective` stay empty. The working pattern is `await loginAs(page, USERS.ana)` at the top of each test (see `tests/fixtures/login.ts`), which exercises the real OAuth flow and waits for the session token to land in localStorage before returning. Adds ~1s per test but is deterministic. 10. **PWA service worker: do not use `injectManifest` with a file named `service-worker.ts`.** SvelteKit intercepts any file at `src/service-worker.[jt]s` and enforces a hard restriction: only `$service-worker` and `$env/static/public` may be imported — Workbox modules are blocked. With `injectManifest`, `@vite-pwa/sveltekit` tries to find the compiled SW output at `.svelte-kit/output/client/service-worker.js`, but SvelteKit never produces it (compilation fails on the blocked imports). Fix for Fase 1–2a: use `generateSW` strategy (plugin auto-generates the SW, no custom source needed). Fix for Fase 2b when a custom SW is needed: name the file `src/sw.ts` — SvelteKit does not recognise it as a service worker — and set `strategies: 'injectManifest'`, `filename: 'sw.ts'` in `vite.config.ts`. ## Sync Strategy | Content | Mechanism | Target latency | |---|---|---| | Items in active shopping session | WebSocket (Supabase Realtime) | < 1 second | | Lists, tasks (outside session) | SSE / polling | < 5 seconds | | Notes | Polling | < 30 seconds | **Offline-first:** all operations execute locally first (IndexedDB), then sync in background. Conflict resolution is last-write-wins. Conflicts are logged to a `sync_conflicts` table for debugging — no CRDT in MVP. ## Critical Platform Gotchas **iOS Safari / PWA:** - The app uses Supabase OAuth PKCE flow (`signInWithOAuth({ provider: 'keycloak' })`). No keycloak-js, no iframe-based silent refresh — Safari-compatible by design. - GoTrue session is persisted in `localStorage` and auto-refreshed by the Supabase client. No manual `updateToken()` needed. - Background Sync API is not supported in Safari. Implement a manual fallback using the `online` event. - Push notifications require iOS 16.4+ and the PWA must be installed to the home screen. - Test the shopping session mode on a real iPhone during development, not only in DevTools. **nginx (prod) — WebSocket for Realtime:** - The `/realtime/` location block **must come before** the `/` block. - `proxy_read_timeout 3600s` is required on the Realtime block — without it, nginx closes WebSocket connections after 60 seconds, forcing continuous reconnects during active shopping sessions. - CSP `connect-src` must include both `https://` and `wss://` for the API domain. **Keycloak behind nginx:** - `X-Forwarded-Host` and `X-Forwarded-Port` headers are mandatory. Without them, Keycloak builds redirect URIs with the internal port (8080) instead of 443, breaking the OIDC flow. **Supabase Realtime self-hosted:** - Check `MAX_REPLICATION_SLOTS` in PostgreSQL and `REALTIME_MAX_CONNECTIONS` before going to production. - Presence subscriptions are more resource-intensive than Postgres Changes — limit them to lists with an active session. ## Dev Test Users Defined in `keycloak/realm-export.json` and `supabase/seed.sql`. All use password `test1234`. | User | Email | Role in test collective | |---|---|---| | `ana` | ana@dev.local | Admin | | `borja` | borja@dev.local | Member | | `carmen` | carmen@dev.local | Member | | `david` | david@dev.local | Guest | | `eva` | eva@dev.local | (no collective — for onboarding testing) | ## Internationalisation Library: **Paraglide JS** (`@inlang/paraglide-sveltekit`). Compile-time, zero runtime overhead, tree-shaken per language. - Message files: `messages/en.json` (default) and `messages/es.json` - Language detection order: `users.language` (if logged in) → `Accept-Language` header → `en` - Language switching: update `users.language` in Supabase + call `setLanguageTag()` — no page reload - **Never hardcode visible user-facing strings in components.** All UI text goes through Paraglide messages. Supported languages: `en`, `es`. ## TypeScript Types `packages/types/src/database.ts` is **generated** by `just db-types` (`supabase gen types typescript`). Never edit it manually. Domain-level types go in `packages/types/src/domain.ts`.