Recorded in plan/fase-5-mobile-ux.md (new §5.14 with the six UX answers)
and CLAUDE.md "Fase 5 — what has been built" section. Also corrected the
"Fase 2a — what has been built" line that still claimed a sticky create
input for /lists, which was replaced by the masthead "New list" button
back in Fase 5.8.
Code change was committed in 3a11914.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
34 KiB
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 complete. Fase 1 complete. Fase 2a complete. Fase 2b complete (Realtime + offline queue + Modo Compra). Fase 3 complete (Tareas + Notas). Fase 4 partially complete (global search + RLS audit). Fase 5 complete for the automatable scope (mobile UX: responsive shell + masthead + row redesign + selection mode + insecure-context resilience; see plan/fase-5-mobile-ux.md). 233 tests green: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 44 Playwright. 2 skipped (Realtime presence — upstream handle_out/3 bug). PWA install/push/rate-limit/JWT rotation deferred to a prod-deploy sprint. ✅
README.md— full development plan, confirmed tech stack, Justfile reference, and technical warningsanalysis/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 5)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—userstable,handle_new_usertriggersupabase/migrations/002_collectives.sql—collectives,collective_members,collective_invitations; auto-promote-oldest-admin triggersupabase/migrations/003_rls.sql— full RLS policies,is_active_member/is_member/is_adminhelpers,accept_invitation()functionsupabase/migrations/004_storage.sql—avatarsbucket (private, signed URLs)apps/web/src/lib/supabase.ts— Supabase singleton with PKCE flowapps/web/src/lib/auth.ts—login()/logout()viasupabase.auth.signInWithOAuthapps/web/src/lib/stores/auth.ts—currentUser,authLoading,isAuthenticated,displayNameapps/web/src/lib/stores/collective.ts—currentCollective,userCollectives,collectiveMembersapps/web/src/lib/components/Avatar.svelte— initials / emoji / upload with deterministic colour hashapps/web/src/lib/components/ImageCropper.svelte— lazy-loaded cropperjs, 1:1, 256×256 WebP outputapps/web/src/routes/+layout.svelte— auth state listener, collective bootstrapapps/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$effectapps/web/src/routes/auth/callback/+page.svelte— PKCE code exchangeapps/web/src/routes/onboarding/+page.svelte— create collective or join via linkapps/web/src/routes/(app)/collective/manage/+page.svelte— members, roles, invite link generatorapps/web/src/routes/invitation/[token]/+page.svelte— accept invitation (auth-aware)apps/web/src/routes/(app)/settings/+page.svelte— display name, avatar, language, sign outapps/web/messages/en.json+es.json— all Fase 1 + 2a stringspackages/types/src/database.ts— manually seeded from migrations (update withjust db-typesonce 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 inkong.ymlbefore 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,pgvitest.config.ts— loads root.envvialoadEnv; single-fork mode (no parallelism races)src/seed-constants.ts— fixed UUIDs for all 5 seed users + collective + seed listsrc/supabase-clients.ts—createClientAs(userId)signs HS256 JWT withSUPABASE_JWT_SECRET;createAdminClient()uses service role keysrc/db-helpers.ts—sql()via rawpgPool for direct DB manipulation (bypasses RLS); used to setdeleted_at = 8 days agoetc.tests/rls-isolation.test.ts— F-series: Eva (non-member) sees zero rows, all writes blockedtests/rls-collective.test.ts— B-series: collective read/update/invite by roletests/rls-lists.test.ts— C-series: list CRUD, soft-delete, trash window, cross-collective isolationtests/rls-items.test.ts— D-series: item CRUD by role, cross-collective item isolationtests/rls-frequency.test.ts— E-series: frequency read-only + trigger populates on INSERT
supabase/tests/*.sqleach start withCREATE EXTENSION IF NOT EXISTS pgtap;so they're idempotent on a fresh DB001_accept_invitation.sql—accept_invitation()happy path + not_found / expired / already_used; usesset_config('request.jwt.claim.sub', …)to setauth.uid()for the test session, and looks up bytoken(notid)002_item_frequency_trigger.sql— trigger normalizes name withlower(trim(...))and incrementsuse_counton 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 frompublic.usersto fire theBEFORE DELETEtrigger and asserts the oldest remaining member is promoted
apps/web/playwright.config.ts— Playwright config;globalSetupis a Keycloak health check (not a session cache),reuseExistingServer: trueapps/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 inbeforeEach; cachedstorageStateis 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 loginapps/web/tests/e2e/lists.test.ts— C-series: create (with reload-persistence check), soft-delete → appears in trash drawer, archive, guest read-onlyapps/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:
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 5 — what has been built (complete in scope)
5.8 big-button create (match /notes)
/listssticky bottom create bar removed; masthead gains a primary "New list" button next to the Trash ghost iconhandleCreatecreates with empty name +goto(/lists/[id]); detail view shows a prominent editable title input (26–32px) with 500 ms autosave (renameList)- Matches
/notespattern 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-100on desktop. Handle'sonpointerdownflips adragEnabledflag so dnd only kicks in from the handle, leaving row swipes untouched. unitcolumn dropped (migration011_drop_shopping_items_unit.sql). The double-tap overlay only edits name, so an unreachableunitwould 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,bulkMoveItemsvia a list picker sheet (data-testid="move-picker") listing the collective's other active lists + a "Create new list" row,bulkCheckItemsfiltersis_checked=falseso it only marks, never toggles.
5.12 detail-view chrome (hide MobileTopBar, sticky contextual bars)
- On
/lists/[id],/tasks/[id],/notes/[id],/lists/[id]/sessionthe globalMobileTopBaris hidden via anisDetailRouteregex check in(app)/+layout.svelte. The route's own contextual header (back + title + actions) becomes the only top chrome and is nowsticky top-0 z-30 bg-surface/80 backdrop-blur-xl. BottomTabBarstays visible so users can hop between sections.
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
selectionModeis active. - Svelte 5 snippets
addItemForm/addTaskFormso 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:flexapps/web/src/lib/components/layout/MobileTopBar.svelte— hamburger + collective name + avatar link;md:hidden sticky top-0 z-30 backdrop-blur-xlapps/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") + optionalmeta/actionssnippets. 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 + restoresapps/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 viascheduleUndoable)apps/web/src/lib/components/ItemSuggestions.svelte— mobile: horizontal scroll with[scrollbar-width:none]; desktop: wraps(app)/+layout.svelte— critical fix: outer container nowflex flex-col md:flex-row(wasflexonly). 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 GENERATEDsearch tsvectorcolumns onshopping_items/tasks/notes+ GIN indexes +search_result_typeenum +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 issimple(no stemmer — bilingual en/es).apps/web/src/lib/stores/search.ts— RPC wrapper withSearchRow+SearchFilterstypes/searchroute — debounced input (300ms, ≥2 chars), filter chips (Lists/Tasks/Notes; all-active = no filter sent), results grouped by type withdata-testid="search-group-<type>", deep-link to the right route per result typeapps/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,taskswith completion-consistency CHECK (is_completed ⇔ completed_by ⇔ completed_at), helpertask_list_collective_id(), RLS by rolesupabase/migrations/009_notes.sql—noteswithnote_colorenum (8 values),is_pinned,is_archived(mutually exclusive via CHECK),deleted_at(7d trash window in RLS),fn_notes_touchtrigger refreshingupdated_at/updated_by,pg_cronjobpurge-deleted-notesat 03:10apps/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 setsupdated_by),pinNote/unpinNote,archiveNote/unarchiveNote,trashNote/restoreNote,permanentDeleteNote,duplicateNote,NOTE_COLORS/tasks(overview) +/tasks/[id](detail withdndzonereorder +flipanimation; lazy-loadscollective_membersfor "Completed by …" attribution; 5s polling per analysis §6)/notes(board with pinned section testidnotes-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 tosupabase_realtimepublication withREPLICA IDENTITY FULLapps/web/src/lib/stores/realtimeSync.ts—subscribeToList(listId, onEvent)postgres_changes subscription helper;applyItemEventreducer 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—SyncQueueclass (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 onwindow.onlineapps/web/src/lib/stores/syncStatus.ts— derived store:offline | syncing | syncedfromnavigator.onLine+pendingOpsCountapps/web/src/lib/components/SyncBanner.svelte— renders the offline/syncing indicator; mounted in list detail + session viewsapps/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 → redirectapps/web/messages/*.json—sync_offline,sync_syncing,list_start_sessionpackages/test-utils/src/realtime-helpers.ts—subscribePostgresChanges(client, opts)withwaitFor(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_configmemory: tenant name, DB_USER=supabase_admin,SEED_SELF_HOST: "true", pre-createdrealtimeschema, publication + REPLICA IDENTITY FULL - Realtime
waitFortimeout is 10s, not 5s. Under fulljust test-allload the Phoenix socket occasionally takes several seconds to propagate a fresh subscription's filter before the test mutation fires. Set inpackages/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 — usegenerateId()from$lib/utils/id.crypto.randomUUIDis 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.randomUUIDisundefinedand every optimistic-id site crasheshandleAddwithTypeError: crypto.randomUUID is not a function.generateId()usescrypto.randomUUID()when available and falls back to an RFC 4122 v4 assembly viacrypto.getRandomValues()(which IS exposed on insecure contexts). Covered bysrc/lib/utils/id.test.ts(U-01..U-04) +tests/e2e/insecure-context.test.ts(INS-01, stubsrandomUUID = undefinedviaaddInitScriptand drives the add-item flow). - Playwright
baseURLis hardcoded tohttp://localhost:5173, ignoringPUBLIC_APP_URL. The LAN-IP variant in root.envis 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_frequencytable,fn_record_item_frequencytrigger (SECURITY DEFINER, fires on everyshopping_itemsINSERT)apps/web/src/lib/stores/lists.ts— shopping lists store (optimistic CRUD) + item operations + suggestion fetchingapps/web/src/lib/components/ItemSuggestions.svelte— frequency chips with active-prefix highlightingapps/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 viasvelte-dnd-action, sticky add form withItemSuggestions
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)
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 <file> # 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):
-
colectivo-webis a confidential client — GoTrue needs a client secret to exchange codes with Keycloak.GOTRUE_EXTERNAL_KEYCLOAK_SECRETmust match Keycloak's client secret (gotrue-dev-secretin dev). -
Custom
openidclient scope in Keycloak — GoTrue v2.158.1 sendsscope=profile email(noopenid) to Keycloak, ignoringGOTRUE_EXTERNAL_KEYCLOAK_SCOPES. Withoutopenidin the token scope, Keycloak's userinfo endpoint returns 401. Fix: a custom client scope namedopenidwithinclude.in.token.scope=trueis set as a default scope oncolectivo-web, so Keycloak injectsopenideven when not requested. -
GOTRUE_DISABLE_SIGNUP: "false"— GoTrue's signup flag blocks OIDC-based user creation too. Must befalse; Keycloak is the gatekeeper for who can register. -
auth.userspre-seeded with Keycloak UUIDs — GoTrue generates its own UUIDs on first login.seed.sqlpre-inserts bothauth.users(withinstance_id='00000000-0000-0000-0000-000000000000'and empty-string token fields) andauth.identities(provider=keycloak, provider_id=Keycloak sub). This ensuresauth.uid()= seed UUID = FK in all public tables. -
Kong env var substitution via
kong-start.sh— Kong 2.8 does not interpolate${VAR}in declarative config files.infra/kong-start.shrunsperl -pe 's/\$\{(\w+)\}/$ENV{$1}/ge'onkong.ymlbefore 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):
-
Do not call
getSession()in a SvelteKitloadfunction to gate auth. Supabase JS v2 initialises its session from localStorage asynchronously (_recoverAndRefresh). In aloadfunction that runs immediately on mount,getSession()can returnnullfor a valid session before initialisation completes — causinglogin()to fire and redirect to Keycloak unnecessarily. The correct pattern:(app)/+layout.tsonly setsssr: false, and(app)/+layout.svelteuses a$effectthat redirects when!$authLoading && !$isAuthenticated. This waits foronAuthStateChangeto fire (which is authoritative). -
onAuthStateChangefiresINITIAL_SESSIONon page load, notSIGNED_IN.SIGNED_INonly 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(...) }coveringINITIAL_SESSION,SIGNED_IN, andTOKEN_REFRESHED. -
CSS custom properties use RGB triplets — always use
rgb(), neverhsl(). All design tokens inapp.cssstore values as space-separated RGB triplets (e.g.51 65 85). Usinghsl(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 anybackground-color/colordeclarations inapp.cssmust usergb(var(--token) / <alpha>). -
Supabase JS v2 + SvelteKit:
loadUserCollectives(or any PostgREST query) insideonAuthStateChangemust be deferred withsetTimeout(..., 0). GoTrue's defaultnavigator.locks-based auth lock is held while theonAuthStateChangecallback runs. Anysupabase.from(...).select(...)called inside that callback callsgetAccessToken() → 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 withsetTimeout(async () => { await loadUserCollectives(session.user.id); … }, 0). As a defensive extra,$lib/supabasepasses a pass-throughauth.lockoption 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. -
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_recoverAndRefreshdoes not reliably re-fireINITIAL_SESSIONfrom a restored context — the page hydrates without triggeringonAuthStateChange, socurrentUser/currentCollectivestay empty. The working pattern isawait loginAs(page, USERS.ana)at the top of each test (seetests/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. -
PWA service worker: do not use
injectManifestwith a file namedservice-worker.ts. SvelteKit intercepts any file atsrc/service-worker.[jt]sand enforces a hard restriction: only$service-workerand$env/static/publicmay be imported — Workbox modules are blocked. WithinjectManifest,@vite-pwa/sveltekittries 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: usegenerateSWstrategy (plugin auto-generates the SW, no custom source needed). Fix for Fase 2b when a custom SW is needed: name the filesrc/sw.ts— SvelteKit does not recognise it as a service worker — and setstrategies: 'injectManifest',filename: 'sw.ts'invite.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
localStorageand auto-refreshed by the Supabase client. No manualupdateToken()needed. - Background Sync API is not supported in Safari. Implement a manual fallback using the
onlineevent. - 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 3600sis required on the Realtime block — without it, nginx closes WebSocket connections after 60 seconds, forcing continuous reconnects during active shopping sessions.- CSP
connect-srcmust include bothhttps://andwss://for the API domain.
Keycloak behind nginx:
X-Forwarded-HostandX-Forwarded-Portheaders 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_SLOTSin PostgreSQL andREALTIME_MAX_CONNECTIONSbefore 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 | 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) andmessages/es.json - Language detection order:
users.language(if logged in) →Accept-Languageheader →en - Language switching: update
users.languagein Supabase + callsetLanguageTag()— 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.