# 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). 248 tests green: 34 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. ### Fase 6+ — production deploy to ambrosio (2026-04-14) Live URLs: **https://erosi.oier.ovh** (app + Supabase API, single-domain) + **https://auth.oier.ovh** (Keycloak). OVH VPS, Ubuntu 24, Docker 29. - `infra/docker-compose.erosi.yml` — full stack bound to `127.0.0.1` only (db, auth, rest, realtime, storage, imgproxy, kong, keycloak, app). `studio` + `meta` intentionally dropped for prod. - `infra/caddy/erosi.Caddyfile` — snippet appended to the host's `/etc/caddy/Caddyfile` between `# BEGIN colectivo-erosi` / `# END colectivo-erosi` markers. Host Caddy 2.11.2 handles TLS (Let's Encrypt via tls-alpn-01). `erosi.oier.ovh` routes `/auth/v1/*`, `/rest/v1/*`, `/realtime/v1/*`, `/storage/v1/*`, `/graphql/v1/*`, `/pg/*` → Kong :8000; everything else → SvelteKit :3000. `auth.oier.ovh` → Keycloak :8090 with `X-Forwarded-Port` explicitly (Caddy auto-sets Proto/Host/For). - `infra/scripts/deploy-erosi.sh` — idempotent redeploy: rsync → generate secrets on first run (via `infra/scripts/rotate-jwt.sh`) → substitute `__KEYCLOAK_CLIENT_SECRET__` in `keycloak/realm-export.erosi.json` → docker compose build + up → run pending migrations → re-install Caddy snippet → reload. - `keycloak/realm-export.erosi.json` — prod realm: `registrationAllowed: true`, redirect URIs scoped to `https://erosi.oier.ovh/*`, client secret placeholder that the deploy script substitutes with a per-install random value. No dev users. - `.env.erosi.example` — committed template. The real `/opt/colectivo/.env` on ambrosio is 600 and stays on the server. - UFW allows `80/tcp`, `443/tcp`, `22/tcp`. All other ports remain DENY. Docker services listen on `127.0.0.1` only, so Kong / Keycloak / the app are unreachable from the public internet except via Caddy. **Prod-specific fixes that had to be made for the stack to boot cleanly on a fresh volume:** - `infra/db-init/00-role-passwords.sh` — rewrote as idempotent bootstrap (see gotcha #17): creates all Supabase service roles if absent, enables `pg_cron` + `pgcrypto` + `uuid-ossp`, creates `auth` / `storage` / `graphql_public` / `_realtime` / `realtime` schemas + empty `supabase_realtime` publication + `keycloak` database + role, sets `supabase_auth_admin` search_path to `auth`. The old `00-role-passwords.sql` (ALTER-only) has been removed. - `apps/web/vite.config.ts` — PWA strategy switched `injectManifest` → `generateSW` (gotcha #16). - Keycloak container uses `start --import-realm` (no `--optimized`) so Quarkus re-builds with the Postgres driver on first boot; with `--optimized` it silently falls back to H2 and fails with "jdbc:h2 URL format error". **Keycloak admin password:** generated per-install — retrieve with `ssh ambrosio 'grep KEYCLOAK_ADMIN_PASSWORD /opt/colectivo/.env'`. Console at https://auth.oier.ovh/admin. **Not yet configured on ambrosio:** SMTP (Resend), automated backups (script exists at `infra/scripts/backup.sh`, not scheduled), Lighthouse run against prod URL, final production icons. ### Fase 6 — what has been built **6.1 PWA `injectManifest` migration** - `apps/web/src/sw.ts` — minimal custom SW: `precacheAndRoute(self.__WB_MANIFEST)` + `skipWaiting`/`clients.claim`. Named `sw.ts` (not `service-worker.ts`) to dodge SvelteKit's built-in interception (see gotcha #10). - `apps/web/vite.config.ts` — switched `@vite-pwa/sveltekit` to `strategies: 'injectManifest'` with `srcDir: 'src'`, `filename: 'sw.ts'`, `injectManifest.globPatterns` for the built shell. - `apps/web/src/routes/+layout.svelte` — registers the SW via `onMount() → import('virtual:pwa-register').registerSW({ immediate: true })`. `@vite-pwa/sveltekit` does NOT auto-register; without this call the virtual module is a no-op and `navigator.serviceWorker.getRegistration()` returns `undefined`. - `apps/web/tests/e2e/pwa.test.ts` — P-01 (manifest shape) + P-02 (SW registration via `waitForFunction` poll). **6.2 Manifest + iOS meta tags + placeholder icons** - Manifest driven by `vite.config.ts` → `SvelteKitPWA({ manifest: {…} })`. Icons declared 192×192, 512×512, 512×512 maskable. `theme_color: #0f172a`, `background_color: #f7f9fb`, `display: standalone`. - `apps/web/static/icons/` — `icon-192.png`, `icon-512.png`, `icon-512-maskable.png`, `icon-180.png` placeholder PNGs (slate background + "C" glyph). Generator: `infra/scripts/generate-placeholder-icons.sh` (ImageMagick). Replace with final artwork before public launch — noted in `static/icons/README.md`. - `apps/web/src/app.html` — iOS PWA meta tags: `apple-mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style`, `apple-mobile-web-app-title`, `apple-touch-icon`. **6.3 Kong rate-limit (invitations only)** - `infra/kong.yml` — `rest-v1-invitations` route: `POST /rest/v1/collective_invitations`, rate-limit `hour: 20, policy: local, limit_by: header (Authorization)`. `strip_path: true` on a full-table path sends `/` upstream which PostgREST rejects with `PGRST117`, so the route also carries a `request-transformer` plugin that rewrites the URI back to `/collective_invitations`. - `infra/docker-compose.dev.yml` — `KONG_PLUGINS` env var gains `rate-limiting` (was missing). - `packages/test-utils/tests/rate-limit.test.ts` — RL-02 only (21st invitation POST = 429). Gated behind `RUN_RATE_LIMIT_TESTS=1` so it's excluded from `just test-all` (Kong counters are shared state). Run via `just test-rate-limit`, which also `--force-recreate`s Kong to reset counters first. - **Scope dropped during execution:** Kong rate-limit on `/auth/v1/token` + `/auth/v1/authorize` removed. Reasoning: Keycloak already offers native brute-force protection on the realm (`bruteForceProtected: true`, `maxFailureWaitSeconds: 900`, `failureFactor: 30` in `keycloak/realm-export.json`), and stacking Kong on top throttles legitimate PKCE code exchanges during E2E (the same `/auth/v1/token` endpoint handles both password auth and code exchange — Kong can't distinguish). See `plan/fase-6-deploy-prep.md` §6.3 for the full rationale. **6.4 JWT rotation + production env template** - `infra/scripts/rotate-jwt.sh` — bash script signing HS256 `anon` + `service_role` JWTs with a freshly-generated 48-byte secret via `openssl dgst -sha256 -hmac + base64url`. Prints the three values (`SUPABASE_JWT_SECRET`, `PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`) for the operator to paste into `.env`. Does NOT touch files. - Dev secret rotated as a dry-run of the procedure. `.env` + `apps/web/.env.development` updated together (see `project_env_keys_trap` memory — both files carry `PUBLIC_SUPABASE_ANON_KEY`). Deduped a pre-existing duplicate `PUBLIC_SUPABASE_ANON_KEY=` line in `.env`. - `.env.production.example` — committed template with placeholders + rotation cadence note. **6.5 Lighthouse + RLS audit tooling** - `Justfile` — `just lighthouse` recipe (prod build → node server on :3000 → `lighthouse --only-categories=pwa,accessibility,best-practices` → HTML report). Requires `just dev` to be up so the real Supabase stack is reachable. - `Justfile` — `just rls-audit` delegates to `infra/scripts/rls-audit.sh`. - `infra/scripts/rls-audit.sh` — signs an Eva JWT (non-member of the seed collective) with `SUPABASE_JWT_SECRET` and GETs eight REST endpoints (`collectives`, `shopping_lists`, `shopping_items`, `task_lists`, `tasks`, `notes`, `collective_members`, `collective_invitations`). Complements `rls-audit.test.ts`; all 8 must return `[]`. **Gotchas discovered during execution (do not remove):** 13. **`strip_path: true` on a full-table Kong route strips too much.** With `paths: [/rest/v1/collective_invitations]` and `strip_path: true`, Kong forwards `/` upstream — PostgREST returns `PGRST117: Unsupported HTTP method: POST` because it tries to route root, not the table. Fix: add a `request-transformer` plugin that `replace.uri: /collective_invitations`. Applies identically to any route where the match path equals the full upstream resource path (as opposed to a prefix match like `/rest/v1/`). 14. **Kong `KONG_PLUGINS` must explicitly list every plugin used in `kong.yml`.** Without `rate-limiting` (or any other non-default plugin) in the comma list, Kong boots with `plugin 'xxx' not enabled; add it to the 'plugins' configuration property`. `infra/docker-compose.dev.yml` currently lists `request-transformer,cors,key-auth,acl,basic-auth,rate-limiting`. 15. **`@vite-pwa/sveltekit` does not auto-register the service worker.** The plugin provides a virtual module (`virtual:pwa-register`, `virtual:pwa-register/svelte`) but you have to call `registerSW()` (or `useRegisterSW()` for Svelte bindings) yourself. Without that call, the SW file is generated + served but never registered, and `navigator.serviceWorker.getRegistration()` returns `undefined`. The canonical place is `apps/web/src/routes/+layout.svelte`'s `onMount()`. 16. **`@vite-pwa/sveltekit` 0.6.8 `injectManifest` hardcodes the source SW filename.** The plugin looks for the compiled SW at `.svelte-kit/output/client/service-worker.js` regardless of `filename: 'sw.ts'`. SvelteKit blocks Workbox imports from `src/service-worker.[jt]s` (gotcha #10), so there's no way to actually feed the plugin. **Workaround:** use `strategies: 'generateSW'` instead — the plugin auto-generates a precache-only SW equivalent to our minimal `src/sw.ts`. `vite.config.ts` currently uses this. Found while building for the ambrosio deploy (2026-04-14); same build was broken on `main` silently. 17. **supabase/postgres v15.1.1.78 does NOT create the Supabase internal roles on a fresh volume.** The image ships with custom extensions + configs but no role-bootstrap script. Dev "works" because its volume was initialised long ago, grandfathering the roles (`authenticator`, `anon`, `authenticated`, `service_role`, `supabase_admin`, `supabase_auth_admin`, `supabase_storage_admin`, `supabase_replication_admin`, `supabase_realtime_admin`, `pgbouncer`, `dashboard_user`). On a fresh volume only `postgres` exists. `infra/db-init/00-role-passwords.sh` now bootstraps them idempotently (create-if-missing) + enables `pg_cron` + creates the empty `supabase_realtime` publication + sets `supabase_auth_admin` search_path to `auth` (without this GoTrue migrations create `factor_type` in `public` and later migrations explode). See `plan/fase-6-deploy-prep.md` §6+ context for the full list of deltas discovered during the ambrosio bring-up. ### Fase 5 — what has been built (complete in scope) **5.8 big-button create (match /notes)** - `/lists` sticky bottom create bar removed; masthead gains a primary "New list" button next to the Trash ghost icon - `handleCreate` creates with empty name + `goto(/lists/[id])`; detail view shows a prominent editable title input (26–32px) with 500 ms autosave (`renameList`) - Matches `/notes` pattern end-to-end **5.10 row redesign (button-row + gesture toggle + overlay + drag handles)** - Shopping items have **no checkbox** in normal mode. Row is `drag-handle | name-button | qty stepper`. Checked items render strikethrough + muted. - **Swipe right on unchecked → mark checked** (green reveal), **swipe left on checked → uncheck** (neutral reveal). Opposite direction is a no-op snap-back. `SWIPE_COMMIT_THRESHOLD=120`, `SWIPE_REVEAL_WIDTH=96`. No undo toast — the gesture is symmetric and the inverse swipe reverses. - **Double-tap opens an overlay** (Dialog / bottom sheet) with: name input + Delete (red) + Confirm + X close. 300 ms tap-window. Delete still routes through `scheduleUndoable` → `UndoToast`. - **Drag handle** on the left, always visible on mobile, `md:opacity-0 md:group-hover:opacity-100` on desktop. Handle's `onpointerdown` flips a `dragEnabled` flag so dnd only kicks in from the handle, leaving row swipes untouched. - `unit` column **dropped** (migration `011_drop_shopping_items_unit.sql`). The double-tap overlay only edits name, so an unreachable `unit` would be stranded data. Users encode units inside the name (e.g. "Milk 1L"); seed updated accordingly. **5.11 selection mode + bulk actions** - **Entry**: long-press 500 ms on mobile or `data-testid="selection-toggle"` button in the desktop header. Preselects the row you pressed (mobile). - Inside selection mode: single-tap toggles selection, swipe + double-tap disabled, stepper + drag handle hidden. Row gains a checkbox on the left. - **Chrome**: header turns into `data-testid="selection-bar"` (dark slate-900 with "N selected" + Cancel + desktop-inline actions). Mobile bottom action bar (`data-testid="selection-action-bar"`) above BottomTabBar with Delete / Move / Mark-checked. - **Bulk actions** (one Supabase call each): `bulkDeleteItems` + single UndoToast with batch snapshot, `bulkMoveItems` via a list picker sheet (`data-testid="move-picker"`) listing the collective's other active lists + a "Create new list" row, `bulkCheckItems` filters `is_checked=false` so it only marks, never toggles. **5.12 detail-view chrome (hide MobileTopBar, sticky contextual bars)** - On `/lists/[id]`, `/tasks/[id]`, `/notes/[id]`, `/lists/[id]/session` the global `MobileTopBar` is hidden via an `isDetailRoute` regex check in `(app)/+layout.svelte`. The route's own contextual header (back + title + actions) becomes the only top chrome and is now `sticky top-0 z-30 bg-surface/80 backdrop-blur-xl`. - `BottomTabBar` stays visible so users can hop between sections. **5.15 detail-view title chrome cleanup** - `/lists/[id]` and `/tasks/[id]` dropped the uppercase eyebrow label ("ACTIVE LIST" / "TASK MANAGER") that used to sit above the editable title. Title moved up into that space. - `/lists/[id]` status signal replaces the hardcoded "ACTIVE LIST": `completed` → title with `text-text-secondary line-through`; `archived` → muted pill next to title (`data-testid="list-status-pill"`); `active` → plain title. - `/tasks/[id]` has no status on `task_lists`; just the label drop. **5.14 inline add-form (last element of the list)** - `/lists/[id]` + `/tasks/[id]` no longer have a bottom-sticky add form. The form renders **inline as the last child of the unchecked / pending section** (just above Checked / Completed). Empty-state branch shows message + form beneath it. - On `/lists/[id]` the frequency chips (`ItemSuggestions`) moved below the input (input row on top, chips on second line). Mobile: horizontal scroll; desktop: wrap. - Hides entirely while `selectionMode` is active. - Svelte 5 snippets `addItemForm` / `addTaskForm` so the form is rendered in both the empty-state branch and the between-sections slot without duplication. - `data-testid="add-item-form"` / `"add-task-form"` hooks added for future placement assertions. ### Fase 5 — earlier sub-phases (still relevant) - `apps/web/src/lib/components/layout/DesktopSidebar.svelte` — desktop sidebar extracted from `(app)/+layout.svelte`, `data-testid="desktop-sidebar"`, `hidden md:flex` - `apps/web/src/lib/components/layout/MobileTopBar.svelte` — hamburger + collective name + avatar link; `md:hidden sticky top-0 z-30 backdrop-blur-xl` - `apps/web/src/lib/components/layout/BottomTabBar.svelte` — 4 nav items icon-only, glassmorphism, `pb-[env(safe-area-inset-bottom)]`, `aria-current="page"` on the active route, `data-testid="bottom-tab-bar"` - `apps/web/src/lib/components/layout/MobileDrawer.svelte` — backdrop + collective switcher + manage/settings/logout, `data-testid="mobile-drawer"` - `apps/web/src/lib/components/ScreenMasthead.svelte` — uppercase label (`data-testid="masthead-label"`) + big title (`data-testid="masthead-title"`) + optional `meta` / `actions` snippets. Applied on `/lists`, `/tasks`, `/notes`, `/search`. - `apps/web/src/lib/sync/undoQueue.ts` — `scheduleUndoable({ label, restore, commit, ttlMs? })` with 4-s TTL; auto-commits on timeout, `undo(id)` cancels + restores - `apps/web/src/lib/components/UndoToast.svelte` — rendered globally in `(app)/+layout.svelte`, shows latest undoable, offset above bottom tab bar on mobile - `/lists/[id]` swipe-delete: `SWIPE_MAX=96`, `SWIPE_THRESHOLD=48` (latch-open), `SWIPE_COMMIT_THRESHOLD=200` (full-swipe commit via `scheduleUndoable`) - `apps/web/src/lib/components/ItemSuggestions.svelte` — mobile: horizontal scroll with `[scrollbar-width:none]`; desktop: wraps - `(app)/+layout.svelte` — **critical fix**: outer container now `flex flex-col md:flex-row` (was `flex` only). Without this MobileTopBar and main rendered as horizontal siblings on mobile and the masthead was pushed off-screen. - Mobile tests: `mobile-shell.test.ts` (M-01..M-04), `mobile-masthead.test.ts` (M-05 × 4 routes), `mobile-swipe-delete.test.ts` (M-06/M-07 `.skip` — Chromium touch emulation too flaky), `undoQueue.test.ts` (5 unit tests) ### Fase 4 — what has been built - `supabase/migrations/010_search_vectors.sql` — STORED GENERATED `search tsvector` columns on `shopping_items`/`tasks`/`notes` + GIN indexes + `search_result_type` enum + `search_in_collective(p_collective_id, p_query, p_types, p_creator, p_from, p_to)` SECURITY INVOKER function (RLS-aware, trash-excluded) + GRANT EXECUTE to anon/authenticated. Config is `simple` (no stemmer — bilingual en/es). - `apps/web/src/lib/stores/search.ts` — RPC wrapper with `SearchRow` + `SearchFilters` types - `/search` route — debounced input (300ms, ≥2 chars), filter chips (Lists/Tasks/Notes; all-active = no filter sent), results grouped by type with `data-testid="search-group-"`, deep-link to the right route per result type - `apps/web/messages/{en,es}.json` — search strings (`search_filter_*`, `search_group_*`, `search_empty`, `search_hint`) - pgTAP `007_search_tsvector.sql` (9 tests: columns, GIN indexes, function signature, generated-vector invariant) - Vitest `search.test.ts` (10 tests across S-01..S-04) + `rls-audit.test.ts` (42 tests: 3 intruders × 14 cross-collective ops — proves zero leakage across every domain table) - Playwright `search.test.ts` (2 tests: SR-01 happy-path, SR-02 filter deactivation) ### Fase 3 — what has been built - `supabase/migrations/008_tasks.sql` — `task_lists`, `tasks` with completion-consistency CHECK (`is_completed ⇔ completed_by ⇔ completed_at`), helper `task_list_collective_id()`, RLS by role - `supabase/migrations/009_notes.sql` — `notes` with `note_color` enum (8 values), `is_pinned`, `is_archived` (mutually exclusive via CHECK), `deleted_at` (7d trash window in RLS), `fn_notes_touch` trigger refreshing `updated_at`/`updated_by`, `pg_cron` job `purge-deleted-notes` at 03:10 - `apps/web/src/lib/stores/tasks.ts` — task lists CRUD (optimistic) + task ops (`addTask`, `completeTask`, `renameTask`, `deleteTask`, `reorderTasks`) - `apps/web/src/lib/stores/notes.ts` — `loadNotes/loadArchivedNotes/loadTrashedNotes`, `createNote`, `patchNote` (always sets `updated_by`), `pinNote/unpinNote`, `archiveNote/unarchiveNote`, `trashNote/restoreNote`, `permanentDeleteNote`, `duplicateNote`, `NOTE_COLORS` - `/tasks` (overview) + `/tasks/[id]` (detail with `dndzone` reorder + `flip` animation; lazy-loads `collective_members` for "Completed by …" attribution; 5s polling per analysis §6) - `/notes` (board with pinned section testid `notes-pinned`) + `/notes/[id]` (editor: title input + textarea, 500ms debounced autosave, color picker, pin/archive/duplicate/trash) + `/notes/archive` + `/notes/trash` (30s polling on board) - `apps/web/messages/{en,es}.json` — full Tareas + Notas string set (`tasks_*`, `notes_*`) - pgTAP `005_tasks_rls.sql` (5 tests on the completion CHECK), `006_notes_trash_purge.sql` (4 tests: inline-runs the purge SQL + verifies pin/archive CHECK) - Vitest `rls-tasks.test.ts` (14 tests) + `rls-notes.test.ts` (15 tests) - Playwright `tasks.test.ts` (3 tests) + `notes.test.ts` (4 tests) ### Fase 2b — what has been built - `supabase/migrations/007_realtime_publication.sql` — adds shopping_items + shopping_lists to `supabase_realtime` publication with `REPLICA IDENTITY FULL` - `apps/web/src/lib/stores/realtimeSync.ts` — `subscribeToList(listId, onEvent)` postgres_changes subscription helper; `applyItemEvent` reducer for INSERT/UPDATE/DELETE - `/lists/[id]/+page.svelte` — wired to realtimeSync with pendingTempIds echo-deduplication. Mutations use client-generated UUIDs so offline retries are idempotent (PK-conflict = success) - `apps/web/src/lib/sync/queue.ts` — `SyncQueue` class (IDB-backed pending_ops, FIFO flush, MAX_ATTEMPTS retry, PK-conflict-as-success) - `apps/web/src/lib/sync/index.ts` — app-wide SyncQueue singleton; `enqueueOp`, `hydrateSyncState`, auto-flush on `window.online` - `apps/web/src/lib/stores/syncStatus.ts` — derived store: `offline | syncing | synced` from `navigator.onLine` + `pendingOpsCount` - `apps/web/src/lib/components/SyncBanner.svelte` — renders the offline/syncing indicator; mounted in list detail + session views - `apps/web/src/routes/(app)/lists/[id]/session/+page.svelte` — Modo Compra full-screen view (fixed positioning overlays the sidebar), 56px toggles, flip animation, confirm modal, completeList → redirect - `apps/web/messages/*.json` — `sync_offline`, `sync_syncing`, `list_start_session` - `packages/test-utils/src/realtime-helpers.ts` — `subscribePostgresChanges(client, opts)` with `waitFor(predicate, ms)` — awaits SUBSCRIBED handshake, dedups events, leaks nothing between tests - Vitest unit harness in `apps/web/` — `vitest.config.ts` (jsdom env) + `vitest.setup.ts` (fake-indexeddb/auto) + `pnpm --filter @colectivo/web test:unit` - Realtime config gotchas documented in `project_realtime_config` memory: tenant name, DB_USER=supabase_admin, `SEED_SELF_HOST: "true"`, pre-created `realtime` schema, publication + REPLICA IDENTITY FULL - **Realtime `waitFor` timeout is 10s, not 5s.** Under full `just test-all` load the Phoenix socket occasionally takes several seconds to propagate a fresh subscription's filter before the test mutation fires. Set in `packages/test-utils/src/realtime-helpers.ts`. Don't drop below 10s — passing runs resolve on event arrival, only the flake window widened. - **Never call `crypto.randomUUID()` directly — use `generateId()` from `$lib/utils/id`.** `crypto.randomUUID` is gated behind secure contexts (HTTPS / localhost / 127.0.0.1 / file://). When the app is served over a bare LAN IP for on-device phone previews (`http://192.168.1.167:5173`), `window.crypto.randomUUID` is `undefined` and every optimistic-id site crashes `handleAdd` with `TypeError: crypto.randomUUID is not a function`. `generateId()` uses `crypto.randomUUID()` when available and falls back to an RFC 4122 v4 assembly via `crypto.getRandomValues()` (which IS exposed on insecure contexts). Covered by `src/lib/utils/id.test.ts` (U-01..U-04) + `tests/e2e/insecure-context.test.ts` (INS-01, stubs `randomUUID = undefined` via `addInitScript` and drives the add-item flow). - **Playwright `baseURL` is hardcoded to `http://localhost:5173`, ignoring `PUBLIC_APP_URL`.** The LAN-IP variant in root `.env` is for on-device phone previews and introduces timing races in the OAuth round-trip that produce flaky failures in the suite. Keep the loopback for deterministic E2E; phone previews are documented below. ### Fase 2a — what has been built - `supabase/migrations/005_shopping.sql` — `shopping_lists`, `shopping_items`, RLS, `list_collective_id()` helper, pg_cron jobs (archive completed > 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`.