CLAUDE.md shrinks to rules + status + index (175 lines, down from 476). Dev setup, prod deploy, and per-phase build records move to docs/. Gotchas regrouped by domain (auth, frontend, PWA, testing, backend, deploy, platform) and unnumbered so they don't drift as new ones land. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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 the "injectManifest hardcoded filename" gotcha below). 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. ✅
Documentation Map
README.md— full development plan, confirmed tech stack, Justfile reference, technical warningsdocs/development.md— local dev stack, repo layout, Justfile + test commands, endpoints, credentials, secretsdocs/deployment.md— production deploy to ambrosio (erosi.oier.ovh) + prod-specific fixesdocs/history/fase-{1,2a,2b,3,4,5,6,7}.md— per-phase record of what was builtanalysis/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)design/slate_collective/DESIGN.md+design/*/screen.png— "Monolith Editorial" direction + per-screen Stitch mockups. Consult before changing UI.
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.
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.
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.
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.
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.
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.
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) |
Gotchas
Auth / OIDC integration
-
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. -
GoTrue v2.158.1 inserts OIDC-provisioned users with empty-string
roleandaud. For users created via the external provider path (Keycloak → GoTrue),auth.users.roleandauth.users.audare left as''instead of the expected'authenticated'. The JWT minted from that row carries the empty role, and PostgREST's per-requestSET LOCAL role = $role_claimthen fails withrole "" does not exist— every REST call errors out before policies or RPCs even run. Dev never surfaced this becauseseed.sqlhardcodes both columns. Migration013_auth_users_role_default.sqlbackfills existing rows and installs a BEFORE INSERT trigger onauth.usersthat 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.
SvelteKit / frontend
-
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. -
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).
PWA / build
-
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. -
@vite-pwa/sveltekitdoes not auto-register the service worker. The plugin provides a virtual module (virtual:pwa-register,virtual:pwa-register/svelte) but you have to callregisterSW()(oruseRegisterSW()for Svelte bindings) yourself. Without that call, the SW file is generated + served but never registered, andnavigator.serviceWorker.getRegistration()returnsundefined. The canonical place isapps/web/src/routes/+layout.svelte'sonMount(). -
@vite-pwa/sveltekit0.6.8injectManifesthardcodes the source SW filename. The plugin looks for the compiled SW at.svelte-kit/output/client/service-worker.jsregardless offilename: 'sw.ts'. SvelteKit blocks Workbox imports fromsrc/service-worker.[jt]s(see the SW-filename gotcha above), so there's no way to actually feed the plugin. Workaround: usestrategies: 'generateSW'instead — the plugin auto-generates a precache-only SW equivalent to our minimalsrc/sw.ts.vite.config.tscurrently uses this.
Testing
-
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. -
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 separate. -
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.
Backend / RLS / Kong
-
strip_path: trueon a full-table Kong route strips too much. Withpaths: [/rest/v1/collective_invitations]andstrip_path: true, Kong forwards/upstream — PostgREST returnsPGRST117: Unsupported HTTP method: POSTbecause it tries to route root, not the table. Fix: add arequest-transformerplugin thatreplace.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/). -
Kong
KONG_PLUGINSmust explicitly list every plugin used inkong.yml. Withoutrate-limiting(or any other non-default plugin) in the comma list, Kong boots withplugin 'xxx' not enabled; add it to the 'plugins' configuration property.infra/docker-compose.dev.ymlcurrently listsrequest-transformer,cors,key-auth,acl,basic-auth,rate-limiting. -
.select()after.insert()evaluates the SELECT RLS policy on the returned row, not just the INSERT policy. PostgREST's chained.insert(...).select().single()addsRETURNING *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 incollective_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 aSECURITY DEFINERRPC that atomically builds the membership state.
Deploy
-
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 onlypostgresexists.infra/db-init/00-role-passwords.shnow bootstraps them idempotently (create-if-missing) + enablespg_cron+ creates the emptysupabase_realtimepublication + setssupabase_auth_adminsearch_path toauth(without this GoTrue migrations createfactor_typeinpublicand later migrations explode). -
docker compose exec -Twithout explicit stdin drains the surrounding heredoc. When a script is delivered over SSH asssh host bash -s << 'REMOTE' ... REMOTE, the outer bash reads its script from stdin. Anydocker compose exec -T <service> <cmd>inside that script — especially inside a command substitution likex=$(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. Causedinfra/scripts/deploy-erosi.shto silently skip migrations 012 and 013 on prod until fixed (had to apply manually after every deploy). Fix: redirect stdin per call with</dev/null(or the file being piped in). Applies to any heredoc-delivered remote script usingdocker compose exec -Torkubectl exec.
Critical Platform
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.