Document what we learned doing the real NetBird+Traefik migration:
- Keycloak realm/client names are operator-chosen; the realm-export.json's
literal "colectivo-web" / "colectivo" values are illustrative, not required.
- Legacy Keycloak /auth/ base path: PUBLIC_KEYCLOAK_URL must include the
suffix when the deployment serves realms under /auth/realms/... (hit this
on auth.fosil.eu). Verify with the discovery URL returning 200.
- NetBird's installer deploys Traefik with idleTimeout=0 (unlimited) by
default — verify instead of prescribing 3600s.
- Runbook: --no-cache fixes the intermittent vite SSR "transforming..."
hang that surfaces as a PostHog shutdown timeout.
- Runbook: any PUBLIC_* change needs an app rebuild (build args); secret
changes only need `docker compose restart auth`.
- Runbook: TRUNCATE recipe for wiping all app + auth data while keeping
schema + migration tracking intact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ambrosio's TLS is now handled by the Traefik that NetBird's self-hosted
installer deploys. kong + app attach to that Traefik network and expose
themselves via Docker-provider labels (erosi-kong priority 100 for
Supabase API paths, erosi-app priority 1 for everything else on
erosi.limonia.net). No container publishes host ports; host Caddy is
out of the deploy path permanently.
Keycloak is no longer bundled — PUBLIC_KEYCLOAK_URL points at an
external IdP. The deploy script writes .env with FILL_IN_* placeholders
for PUBLIC_KEYCLOAK_URL, KEYCLOAK_CLIENT_SECRET, and TRAEFIK_NETWORK,
and fails fast until they are filled.
New domain: erosi.limonia.net. realm-export.erosi.json is retained
as reference for the external Keycloak operator; it is no longer
imported by this stack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fase 9 bundles dark mode as the flagship feature plus the four polish items
parked from Fase 5/6 (sync_conflicts wiring, presence avatars still blocked
on supabase/realtime #1617). Fase 10 closes the completion debt found in the
2026-04-22 audit: CU-H06/H07/H08 (leave/dissolve/transfer admin), trash
restore, reset-from-detail, sidebar create-collective, account deletion, and
automatic language detection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generated via /graphify over the full tree (177 files, ~320k words).
Produces graph.html, graph.json, GRAPH_REPORT.md plus per-file
extraction cache so incremental --update runs reuse prior results.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- CLAUDE.md status line + logout paragraph + migration 013 gotcha updated
to cover the INSERT+UPDATE two-step GoTrue behaviour and the RP-initiated
logout flow; new SvelteKit gotcha block spelling out the signOut→$effect
race that produced "PKCE code verifier not found in storage" and how the
isLoggingOut flag + /logged-out public route close it.
- docs/history/fase-8-auth-logout.md captures both prod bugs with symptom,
root cause, fix, the Keycloak id_token_hint tradeoff (why the one-click
confirmation page is accepted UX), and the test deltas.
- docs/development.md auth.ts one-liner updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clicking logout previously only called supabase.auth.signOut(): Keycloak's
SSO cookie was untouched, so the (app) layout's $effect immediately
re-fired login() and Keycloak silently re-authenticated — logout looked
broken. That same chain also surfaced "PKCE code verifier not found in
storage" on prod, since signOut()'s async storage clear could race
signInWithOAuth()'s verifier write, or the $effect could double-fire and
overwrite verifiers mid-flight.
- logout(): signOut() then redirect to Keycloak's end_session endpoint
(client_id + post_logout_redirect_uri=/logged-out). Keycloak shows a
one-click "Do you want to log out?" confirmation because GoTrue's proxy
flow does not expose the id_token, so we can't pass id_token_hint.
- isLoggingOut flag + guard in (app) layout $effect: prevents auto-relogin
during the logout navigation.
- New public /logged-out route, outside the (app) group.
- A-05 rewritten, new A-07 (fresh login must prompt for credentials after
RP-initiated logout).
- pgTAP 008 extended with 4 assertions for migration 014's UPDATE trigger.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GoTrue v2.158.1 emits an UPDATE right after inserting an OIDC-provisioned
user (pop ORM resends the full struct incl. the zero-value role=''),
which bypassed migration 013's BEFORE INSERT trigger and left prod users
with role='' — breaking every PostgREST call with `role "" does not exist`.
Add a matching BEFORE UPDATE trigger reusing the same guard function, and
re-backfill the rows already clobbered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Symptom observed twice (migrations 012 and 013): deploy-erosi.sh ran the
migration loop's first iteration (the `CREATE TABLE IF NOT EXISTS
_applied_migrations` step) then silently skipped every file in
supabase/migrations/*.sql without printing a single skip/apply line. Had
to `psql -f <new-migration>` by hand on ambrosio after every deploy.
Root cause: the deploy uses `ssh host bash -s << 'REMOTE' ... REMOTE` so
the outer bash on the remote reads its own script from stdin. Any
`docker compose exec -T db psql ...` inside that script — particularly
inside a command substitution like `already=$(docker compose exec -T ...)`
— inherits that stdin and consumes it, eating the rest of the heredoc.
Fix: pass `</dev/null` (or the migration file for the actual `apply`
step) to every `docker compose exec -T` call so docker gets an empty /
file-scoped stdin instead of the parent's heredoc.
Verified on ambrosio: deploy now prints all 13 `skip ... (applied)` lines
as expected, and will apply new migrations going forward without manual
intervention.
- CLAUDE.md: gotcha #20 documenting the pattern; applies to any
heredoc-delivered remote script using `docker compose exec -T` or
`kubectl exec`.
Found on ambrosio after the first real self-registration: GoTrue v2.158.1
persists OIDC-provisioned users with empty-string role and aud. PostgREST
then dies with `role "" does not exist` on every REST call because its
per-request `SET LOCAL role = $claim` can't resolve ''. Dev never caught
this because seed.sql hardcodes both columns.
- supabase/migrations/013_auth_users_role_default.sql: backfill existing
rows + BEFORE INSERT trigger on auth.users that fills empty/NULL role +
aud with 'authenticated'. Idempotent, preserves explicit values.
- supabase/tests/008_auth_user_role_default.sql: pgTAP coverage — function
+ trigger exist, backfill left nothing empty, empty-string and NULL
values get defaulted, explicit non-empty role is preserved.
- CLAUDE.md: status line updated to 255 tests (was 248), new gotcha #19
documenting the GoTrue quirk + that users in existing sessions must
re-login once for a fresh JWT after the fix lands on any given env.
Total: 41 pgTAP (was 34) + 140 integration + 15 unit + 58 Playwright + 1
gated rate-limit. Full `just test-all` green.
12 new Playwright tests closing the UI coverage gap in the collective
lifecycle (onboarding → invitation → admin manage). Writing them surfaced
three product-code bugs that have silently been present since Fase 1;
fixed as part of this phase.
Tests:
- tests/e2e/onboarding.test.ts O-01..O-03 (Eva auto-redirect, happy
path create, empty-name submit-disabled)
- tests/e2e/invitation.test.ts I-01..I-05 (token generation, accept
logged-out + logged-in, expired, used)
- tests/e2e/manage-collective.test.ts MC-02..MC-05 (promote, demote, remove
member, generate pending invite)
- tests/fixtures/db.ts resetEva, seedExpiredInvitation,
seedUsedInvitation, restoreSeedMembership,
countMembership — all via raw SQL so they
don't depend on SUPABASE_SERVICE_ROLE_KEY
Bugs found & fixed:
- supabase/migrations/012_create_collective_rpc.sql: atomic
`create_collective(name, emoji)` SECURITY DEFINER RPC. Fase 1 onboarding
used `.insert(...).select().single()` which triggers the SELECT policy on
the fresh row — creator isn't a member yet, so the INSERT was rejected with
"row-level security" even though the INSERT policy passed. onboarding now
calls the RPC which inserts both the collective AND the creator-as-admin
membership in one transaction, bypassing RLS. F-08 in rls-isolation.test.ts
has been silently masking this bug (only the spoof branch was asserted).
- routes/+layout.svelte: post-login redirect now reads sessionStorage
`pendingInvitationToken` and routes back to /invitation/<token> instead of
defaulting to /onboarding or /lists. The invitation page already stashed
the token before kicking off Keycloak, but nothing read it back.
- routes/(app)/collective/manage/+page.svelte: loadMembers now subscribes
to currentCollective so it re-runs when the store resolves after cold
navigation. onMount alone races with the auth listener's first emit and
the member list stayed empty on direct URL hits.
- routes/invitation/[token]/+page.svelte: awaits authLoading before deciding
whether to redirect to login. Previously raced with the auth listener and
triggered a redundant Keycloak round-trip for freshly-authenticated users.
Stable selectors added (Playwright):
- Onboarding: onboarding-create-tab, onboarding-join-tab,
collective-name-input, collective-submit
- Manage: member-row-<uid>, role-select-<uid>, remove-member-<uid>,
generate-invite, invite-link
- Invitation: invitation-accept, invitation-error (+ data-error-key attr)
Scope dropped from the plan — UI does not exist, would be feature work:
- O-04: "+ new collective" affordance in the sidebar.
- MC-01: rename-collective input in /collective/manage.
Both flagged as follow-ups in plan/fase-7-collective-flow-tests.md.
Test totals: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 58
Playwright + 1 gated rate-limit. 3 skipped (2 Realtime presence, 1 gated).
- plan/fase-7-collective-flow-tests.md: 14 new Playwright tests (O-01..O-04
onboarding, I-01..I-05 invitation acceptance, MC-01..MC-05 admin manage)
plus shared fixtures/db.ts helpers (resetEva, seedExpiredInvitation,
seedUsedInvitation, restoreSeedMembership). Risks + scope-out documented.
- CLAUDE.md: add "Fase 6+ production deploy to ambrosio" section with the
files / config / discovered fixes for the prod stack; link to the new plan
from the Project Status line; add gotchas #16 (injectManifest filename
hardcoded in @vite-pwa/sveltekit 0.6.8) and #17 (supabase/postgres doesn't
bootstrap its internal roles on a fresh volume).
Self-contained compose for a single-VPS deploy behind the host's Caddy.
App + Supabase API share erosi.oier.ovh; Keycloak on auth.oier.ovh.
- infra/docker-compose.erosi.yml — full stack bound to 127.0.0.1 only
(db, auth, rest, realtime, storage, imgproxy, kong, meta, keycloak, app)
- infra/caddy/erosi.Caddyfile — host-Caddy snippet with TLS + path routing
(/auth /rest /storage /realtime /graphql → kong, rest → app)
- infra/scripts/deploy-erosi.sh — rsync + first-run secret generation +
build + migrations + Caddy snippet install
- keycloak/realm-export.erosi.json — prod redirect URIs, registration on,
client secret as __KEYCLOAK_CLIENT_SECRET__ placeholder (deploy sub'd)
- .env.erosi.example — env template with placeholders
Supporting fixes to make the deploy work on a clean Supabase-postgres volume:
- infra/db-init/00-role-passwords.sh — rewrote as idempotent bootstrap:
CREATE the Supabase service roles if missing, set passwords on pre-existing
ones, enable pg_cron/pgcrypto/uuid-ossp, create auth/storage/graphql_public/
_realtime/realtime schemas, create empty supabase_realtime publication,
set per-role search_path (auth→auth, storage→storage). Old version only
ALTERed passwords and relied on the roles already existing — worked for a
grandfathered dev volume, failed on a fresh prod init.
- infra/db-init/00-role-passwords.sql — removed (folded into .sh).
- apps/web/vite.config.ts — PWA strategy generateSW (was injectManifest).
The plugin's injectManifest hardcodes the SW source filename to
service-worker.js and ignores the filename: 'sw.ts' override, making the
production build fail. generateSW's auto-generated precache-only SW is
functionally equivalent to our 5-line src/sw.ts.
Tested end-to-end on ambrosio: all 9 containers up, 11 migrations applied,
https://erosi.oier.ovh returns 200, Keycloak OIDC discovery serves the
correct issuer, /auth/v1/settings lists Keycloak as the sole external
provider.
Closes the deferrals from Fase 4 that gated a public deploy of the MVP.
PWA install
- Custom SW at apps/web/src/sw.ts (named sw.ts, not service-worker.ts, so
SvelteKit doesn't intercept it and block Workbox imports).
- vite.config.ts switched to injectManifest strategy with precache of the
built shell; no runtime caching of Supabase (offline writes stay on the
existing $lib/sync pending_ops queue).
- Root +layout.svelte onMount() calls registerSW({ immediate: true }) from
virtual:pwa-register — the plugin does not auto-register.
- Web manifest with 192/512/512-maskable icons + iOS meta tags
(apple-mobile-web-app-capable, apple-touch-icon, etc.) in app.html.
- Placeholder icons generated via ImageMagick; replace before public launch
(noted in apps/web/static/icons/README.md).
Kong rate-limit
- /rest/v1/collective_invitations POST: 20/hour per Authorization header.
Anti-spam on invitation creation is the only rate-limit that survived —
auth rate-limits were dropped during execution (see plan §6.3): Keycloak
already provides bruteForceProtected=true on the realm, and stacking a
Kong limit on /auth/v1/token throttled legitimate PKCE code exchanges
during E2E.
- Added 'rate-limiting' to KONG_PLUGINS in docker-compose.dev.yml.
- Discovered: strip_path=true on a full-table path sends "/" upstream and
PostgREST rejects POST to root with PGRST117. Route carries a
request-transformer plugin that rewrites the URI back.
JWT rotation + prod template
- infra/scripts/rotate-jwt.sh signs anon + service_role JWTs with a fresh
48-byte secret via openssl. Prints three values for the operator to
paste; does not touch files.
- Dev secret rotated as a dry-run. Updated both .env (local, gitignored)
and apps/web/.env.development. Existing sessions are invalidated — all
users must re-login once.
- .env.production.example committed with placeholders + rotation notes.
Lighthouse + RLS audit tooling
- `just lighthouse` boots the prod build + Supabase stack and runs
lighthouse CLI against PWA/A11y/Best-Practices.
- `just rls-audit` runs infra/scripts/rls-audit.sh which signs an Eva JWT
(non-member) and GETs 8 REST endpoints — all must return []. Complements
the Vitest rls-audit.test.ts suite.
Tests
- 236 green: 34 pgTAP + 140 Vitest integration + 15 unit + 46 Playwright.
- 1 rate-limit test gated behind RUN_RATE_LIMIT_TESTS=1 (Kong counters are
shared state); run via `just test-rate-limit` which force-recreates Kong
first to reset counters.
- 3 skipped in `just test-all` (2 Realtime presence upstream bug, 1 gated
rate-limit).
Deliberately out of scope: push notifications, CI ephemeral Docker, final
production icons, runtime caching of Supabase (SyncBanner + pending_ops
already covers offline).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New plan file closing out the Fase 4 deferrals with specifics confirmed
by user on 2026-04-14:
- 6.1 PWA injectManifest, MINIMAL scope (precache shell only; Supabase
GETs stay network-only since $lib/sync + SyncBanner already cover
offline writes + the offline indicator)
- 6.2 manifest.webmanifest + neutral placeholder icons (192/512/180 +
maskable 512) + iOS meta tags
- 6.3 Kong rate-limiting — 10/min + 60/hour on /auth/v1/token per IP,
30/min on /auth/v1/authorize per IP, 20/hour on invitations POST
per authenticated user
- 6.4 JWT rotation — dev rotate now + .env.production.example template
+ infra/scripts/rotate-jwt.sh
- 6.5 Lighthouse PWA ≥ 90 + scripted rls-audit.sh (curl edition of
Vitest's rls-audit.test.ts)
- 6.Z verification
Explicitly out of scope (recorded at bottom of the plan):
- Push notifications (no current use case)
- Ephemeral-Docker CI (deferred)
- Runtime caching of Supabase in the SW (deferred)
- Final production icon art (placeholders land now; real art before
public launch)
README phase table + index updated; plan total now 11–16 weeks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Recorded in plan/fase-5-mobile-ux.md (new §5.15 right next to the
existing 5.13 / 5.14 sections) and CLAUDE.md "Fase 5 — what has been
built" with the list.status → visual signal table.
Code change landed in 73bc51e.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Detail views (/lists/[id], /tasks/[id]) no longer show the editorial
uppercase label above the editable title. The title moves up into that
space for a cleaner top.
Status signaling on /lists/[id] (replaces the hardcoded "ACTIVE LIST"):
- list.status === 'completed' → title renders text-text-secondary + line-through
- list.status === 'archived' → muted pill next to the title
(data-testid="list-status-pill", slate-200 bg)
- list.status === 'active' → no extra chrome, just the title
/tasks/[id] has no status on task_lists — just the label drop.
Verified: just test-all → exit 0, 233 green, 2 skipped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
UX change per direct user request:
- Add-item / add-task form no longer sticks to the bottom of the viewport.
It's now the last element of the unchecked/pending section, sitting
just above the Checked/Completed section (option `a` of the review).
- On /lists/[id] the frequency suggestion chips moved from ABOVE the
input to BELOW it, forming a two-line block (input row on top, chips
on the second line). Chips keep their responsive behavior — horizontal
scroll on mobile, wrap on desktop (ItemSuggestions already handles
that).
- Empty-state message still shows when the list has no items; the form
renders below it so you can add the first item without scrolling.
- The form disappears while selection mode is active on /lists/[id]
(selection chrome takes over the bottom).
Implementation
/lists/[id]/+page.svelte
- Removed the `absolute bottom-0` sticky form block.
- New `addItemForm` Svelte snippet rendered at the end of unchecked
items AND in the empty-state branch, gated by `!selectionMode`.
- ItemSuggestions moved inside the snippet, below the input.
- pb-32 scroll padding reduced to pb-16/md:pb-6 (no more sticky
overlapping the last row).
/tasks/[id]/+page.svelte
- Same treatment with an `addTaskForm` snippet (no suggestions).
Tests
All existing D- / T-UI- / selection tests pass unchanged: the locators
are placeholder-based and the form is still findable regardless of
placement. `data-testid="add-item-form"` / `"add-task-form"` added as
future-proof hooks.
Verification
just test-all → exit 0, 233 green, 2 skipped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Problem
The phone-preview path (http://192.168.1.167:5173) is a non-secure
origin. `window.crypto.randomUUID` is gated behind secure contexts
(HTTPS / localhost / 127.0.0.1 / file://) so it's undefined there,
and every optimistic-id call site crashed handleAdd with
`TypeError: crypto.randomUUID is not a function`.
Fix
apps/web/src/lib/utils/id.ts — generateId() uses crypto.randomUUID()
when available, otherwise falls back to crypto.getRandomValues()
+ RFC 4122 §4.4 byte assembly. getRandomValues IS exposed on
insecure contexts (unlike subtle), so the fallback is v4-compliant
and cryptographically acceptable for client-side optimistic ids.
Replaced crypto.randomUUID() at every call site:
stores/lists.ts, stores/tasks.ts, sync/queue.ts, sync/undoQueue.ts
routes/(app)/lists/[id]/+page.svelte
routes/(app)/tasks/[id]/+page.svelte
CLAUDE.md gotcha added: never call crypto.randomUUID() directly —
always import generateId from $lib/utils/id.
Tests (written first, confirmed failing on main)
src/lib/utils/id.test.ts (4 tests)
U-01 returns UUID v4 when randomUUID is available
U-02 falls back when randomUUID is undefined
U-03 50 fallback-generated ids are all unique
U-04 prefers randomUUID when present (doesn't drop into fallback)
tests/e2e/insecure-context.test.ts (1 test)
INS-01 stubs crypto.randomUUID = undefined via addInitScript,
drives the add-item flow, confirms the row renders (if the
old call site were still there, handleAdd would throw).
Verification
just test-all → exit 0, 233 green, 2 skipped:
34 pgTAP (unchanged)
140 Vitest integration + 2 skipped presence (unchanged)
15 Vitest unit (was 11, +4 generateId)
44 Playwright (was 43, +1 INS-01)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two concrete problems the user hit:
1) /tasks overview had no visible "create" button — only an enter-to-submit
input hidden behind a decorative Plus icon.
2) /tasks/[id] add-task form had no visible submit button either.
Both now mirror the /lists + /notes pattern:
/tasks — new "New list" button in the masthead (bg-slate-900 primary
style matching "New list" on /lists and "New note" on /notes). Click
creates an empty task_list and navigates to /tasks/[id] where the
user names it inline via the new big editable title input
(autosaves after NAME_SAVE_DEBOUNCE_MS, 500 ms — same hook as
/lists/[id] and /notes/[id]).
/tasks/[id] — sticky add-task footer gains a filled "+" submit button
on the right; clicking it or pressing Enter both fire handleAdd.
Input is now wrapped in the same rounded-lg card used on /lists/[id]
so the two screens look identical.
i18n
tasks_new_list = "New list" / "Nueva lista" (en / es).
Tests
tasks.test.ts helper updated to drive the new button-then-title flow
(same shape as lists.test.ts). T-UI-01..03 all pass.
just test-all → exit 0, 228 green, 2 skipped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The file tested a Fase 2b.5 interaction — full-swipe commits a delete
with an undo toast — that Fase 5.10 replaced. Swipes on the list detail
view are now a symmetric check/uncheck toggle; delete lives in the
double-tap overlay and in selection mode. The two tests were
describe.skip'd since Fase 5.10 and the UX they describe doesn't exist
anymore.
The swipe-toggle gesture that replaced it is still untested at the E2E
level because Chromium touch emulation can't drive pointer/touch events
into Svelte's component handlers reliably. Adding `playwright install
webkit` to CI would unblock a fresh `mobile-swipe-toggle.test.ts` with
M-06 swipe-right-check, M-07 swipe-left-uncheck, and M-08 wrong-
direction snapback. Noted in plan/fase-5-mobile-ux.md §5.10.b.
Verification
just test-all → 228 green, 2 skipped (both are the upstream
Realtime presence bug in realtime-presence.test.ts; everything else
passes).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause of the recurring "just test-all shows 27/16, playwright alone
shows 43/0" split:
Justfile loads .env with dotenv-load, which sets PUBLIC_APP_URL to the
LAN IP from the phone-preview config. playwright.config.ts was reading
that env and using it as baseURL. The resulting LAN-IP OAuth round-trip
(browser → GoTrue → Keycloak → browser) adds ~hundreds of ms of
loopback routing per hop and pushes the $currentCollective hydration
past Playwright's first synthetic keystroke, producing a silent early
return in handleAdd (name typed but Enter never triggers a write).
Running playwright directly skipped dotenv-load, baseURL fell back to
localhost, and everything passed. Same code, same machine, different
wrapper.
Fix
apps/web/playwright.config.ts: baseURL hardcoded to 'http://localhost:5173'.
The LAN IP stays in PUBLIC_APP_URL for on-device phone previews (step
2/3 of the mobile-testing recipe in CLAUDE.md), but e2e always hits
the loopback so the timing profile is deterministic.
apps/web/tests/fixtures/login.ts: origin-check hardcoded to localhost
to match.
Verification
just test-all → exit 0, 228 tests green (34 pgTAP + 140 Vitest
integration + 11 Vitest unit + 43 Playwright), 4 skipped (upstream
Realtime presence + WebKit-only touch gestures).
just test-e2e alone → 43 passed.
pnpm exec playwright test alone → 43 passed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fase 5 is ✅ complete in automatable scope after the last five feature
commits (d6cea51, 4cc5f69, d14d6cd, 1cb408a, f3e8234). 228 tests green,
4 skipped. Deferred: WebKit-dependent swipe/long-press E2E, card
presence avatars, max-w-2xl reading width.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Long-press on mobile or the new header toggle on desktop puts
/lists/[id] into multi-select mode. Rows render a checkbox on the left,
the stepper + drag handle go away, and swipes + double-tap are disabled.
Chrome during selection mode
Mobile: header turns into a dark slate-900 action bar with "N selected"
+ Cancel on the left, and a bottom action bar (Delete / Move / Check)
appears above the BottomTabBar. The sticky add-item form is hidden.
Desktop: same dark header with Cancel + inline action icons on the right.
Bulk actions
Delete — one scheduleUndoable with a batch restore (re-inserts all
snapshots) / batch commit (bulkDeleteItems). Single undo toast.
Move — opens a bottom sheet / dialog with the collective's other active
lists + a "Create new list" row that creates an empty list and moves
into it in a single gesture.
Mark checked — flips only the selected unchecked items
(bulkCheckItems with is_checked=false filter), no toggle.
Store helpers (apps/web/src/lib/stores/lists.ts)
bulkDeleteItems(ids) → DELETE .in('id', ids)
bulkMoveItems(ids, newListId) → UPDATE list_id .in('id', ids)
bulkCheckItems(ids, userId) → UPDATE is_checked=true where !is_checked
Entry / exit
Long-press 500 ms on a row enters with that row pre-selected. Movement
cancels the long-press intent so it never fights the swipe.
Single-tap toggles selection while active (instead of invoking the
no-op short-tap / dbltap-overlay path).
Cancel button exits + clears selection.
Tests
apps/web/tests/e2e/selection.test.ts (3 desktop tests)
SEL-01 toggle enters, row click toggles, Cancel exits
SEL-02 bulk delete → row gone + undo toast visible
SEL-03 mark checked → row gains strikethrough
Mobile long-press tests deferred until WebKit install lands (same
reason as the swipe-toggle M-series).
i18n additions
list_select, list_cancel_selection, list_selection_count({n}),
list_bulk_delete/move/mark_checked, list_undo_bulk_delete({n}),
list_move_title, list_move_create_new (en/es).
Verification
just test-e2e → 43 passed + 2 skipped (was 40 + 2).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the checkbox/hover-delete model with an explicit gesture+overlay
flow on /lists/[id].
Row anatomy (new)
[drag-handle] [name as button] [qty stepper − N +]
- No checkbox in normal mode.
- Checked items render with strikethrough + muted text.
- Drag handle always visible on mobile; revealed on hover on desktop
(md:opacity-0 md:group-hover:opacity-100). The handle owns the drag
(pointerdown → dragEnabled=true), so row swipes + taps keep working.
Swipe gestures (symmetric toggle, no delete)
unchecked + swipe RIGHT → mark checked (green success reveal)
checked + swipe LEFT → mark unchecked (neutral reveal)
opposite direction for each state is a no-op (snaps back).
SWIPE_COMMIT_THRESHOLD=120 (was 200 for delete); REVEAL_WIDTH=96.
Symmetry means undo is the inverse swipe — no undoQueue on toggles.
Double-tap overlay (name + delete + confirm)
Short tap: no-op. Two taps within DOUBLE_TAP_WINDOW_MS (300 ms) open a
full-screen dialog (bottom sheet on mobile) with:
- X close button (top-right)
- name input
- Delete button (red) → scheduleUndoable → UndoToast
- Confirm button (primary) → updateItem({ name })
Click outside / Escape / X all dismiss without saving.
Tests
items.test.ts
D-03 rewritten: double-tap opens overlay, value matches, X closes.
D-04 rewritten: overlay Delete removes the row; toast locator scoped
out of itemRow so it doesn't shadow the assertion.
realtime.test.ts
R-E-02 updated: Ana renames via overlay → Borja sees new name over
Realtime UPDATE. (The check/uncheck path is now gestural only and
chromium touch emulation is too flaky to drive it in E2E; Vitest
integration R-02 still covers the UPDATE-row-payload invariant.)
mobile-swipe-delete.test.ts stays describe.skip — will be renamed and
rewritten for the new semantics once WebKit install lands in CI.
i18n
list_qty_decrease, list_qty_increase, list_reorder_handle,
list_edit_item, list_confirm, list_close — en/es.
Verification
just test-e2e → 40 passed, 2 skipped (same as pre-change).
Type-check clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove the sticky bottom create input from /lists. The masthead now hosts
a primary "New list" button next to the Trash icon, styled identically to
the "New note" button on /notes. Click → createList with an empty name →
goto /lists/[id], where a prominent editable title input (above To buy /
Checked) autosaves on blur / input with a 500 ms debounce.
Changes
apps/web/src/routes/(app)/lists/+page.svelte
- remove `newListName`, `nameInput`, `handleKeydown`
- remove the absolute-positioned sticky bottom block
- handleCreate now creates with name = '' and navigates to the detail
- actions snippet gains the primary New list button; Trash button
demoted to ghost icon
apps/web/src/routes/(app)/lists/[id]/+page.svelte
- new listName / scheduleNameSave / flushNameSave state (mirrors the
/notes editor pattern)
- big editable title <input> at top of content, autosaves on blur
via renameList
- contextual header h1 trimmed to a secondary caption-size preview
on mobile (so the user still sees the list name at the top)
- flushNameSave on component destroy so unsaved renames commit
apps/web/messages/{en,es}.json: lists_new_list = "New list"/"Nueva lista"
Tests
apps/web/tests/e2e/lists.test.ts: createList helper now clicks the
button, fills the title input, blurs, waits for autosave, and goes
back to /lists to present the same "list-is-on-the-overview" state
that C-11 / C-12 expect. C-07 picks up the reload check.
apps/web/tests/e2e/session.test.ts: S-03 mirrors the new flow for its
fresh-list setup.
Verification
just test-e2e → 40 passed, 2 skipped (swipe-delete .skip remains).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On /lists/[id], /tasks/[id], /notes/[id], /lists/[id]/session the global
MobileTopBar (hamburger + collective + avatar) is hidden on mobile. The
route's own contextual header (back + title + actions) becomes the only
top chrome and is now sticky with glassmorphism, matching the look of
the MobileTopBar it replaces. BottomTabBar stays visible so the user can
still hop between sections.
Detection: a regex over the path — /^\/(lists\/[uuid](\/session)?|
tasks\/[uuid]|notes\/[uuid])\/?$/ — in (app)/+layout.svelte.
Headers touched:
- apps/web/src/routes/(app)/lists/[id]/+page.svelte (px-4 on mobile,
px-8 on md+, transparent/no-border on md+ since the masthead
provides its own spacing)
- apps/web/src/routes/(app)/tasks/[id]/+page.svelte
- apps/web/src/routes/(app)/notes/[id]/+page.svelte
Tests
tests/e2e/mobile-shell.test.ts gains M-08: on /lists/bbbb..., the
mobile-hamburger element has count 0 (MobileTopBar removed) while the
detail's back link + BottomTabBar remain visible. All 5 shell tests
green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The unit field had no edit path under the redesign (only name + qty
remain in the row; the double-tap overlay is name-only). Users can encode
the unit inside the name when it matters — e.g. "Milk 1L", "Eggs 1 doz".
Changes
supabase/migrations/011_drop_shopping_items_unit.sql — DROP COLUMN
supabase/seed.sql — unit values folded into names where relevant
supabase/tests/002_item_frequency_trigger.sql — remove unit from INSERT
packages/types — remove unit from ShoppingItem Row/Insert/Update
apps/web/src/lib/stores/lists.ts — addItem/updateItem signatures
apps/web/src/routes/(app)/lists/[id]/+page.svelte — purge newUnit /
editUnit state + inputs + display
apps/web/src/routes/(app)/lists/[id]/session/+page.svelte — simplify
quantity-only render
apps/web/messages/{en,es}.json — remove list_unit_label
Verification
just test-db → 34 pgTAP green
just test-integration → 140 + 2 skipped
just test-unit → 11 green
just test-e2e → 39 + 2 skipped
The generated search tsvector on shopping_items.search already indexes
only `name`, so full-text search coverage is unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The dev stack previously mixed two hostnames (`localhost` in some places,
`keycloak` in others). That only worked because the laptop's /etc/hosts
maps `keycloak` to 127.0.0.1. Phones, tablets, or a second laptop had no
such mapping, so the OAuth flow broke the moment you tried to log in from
any non-laptop device.
Switched the SPA, Kong, GoTrue, and Keycloak client to all agree on a
single external host — the laptop's LAN IP (192.168.1.167) — so the full
Keycloak → GoTrue → Supabase → SvelteKit round-trip works from any device
on the same Wi-Fi.
Changes
.env (root, ungitignored): PUBLIC_SUPABASE_URL / PUBLIC_KEYCLOAK_URL /
PUBLIC_APP_URL now use the LAN IP (updated locally; tracked via
apps/web/.env.development below).
apps/web/.env.development: mirror the LAN IP so `$env/static/public`
resolves the same on laptop and phone.
apps/web/vite.config.ts: `server.host: true` — bind Vite to 0.0.0.0.
apps/web/playwright.config.ts: `baseURL: PUBLIC_APP_URL ?? localhost`
so E2E uses the same origin the stack is configured with.
apps/web/tests/fixtures/login.ts:
- wait for the collective button in the sidebar to render before
returning (LAN-IP latency surfaced a pre-existing race in
`handleCreate` where Enter fired before `$currentCollective`
hydrated, silently no-op'ing).
- derive expected app origin from PUBLIC_APP_URL.
apps/web/tests/e2e/items.test.ts: scope D-04 delete assertion to the
`[role="listitem"]` locator — with undoQueue enabled the toast text
"Deleted <name>" also matches `getByText(itemName)` and shadowed the
original check.
infra/docker-compose.dev.yml:
- GOTRUE_EXTERNAL_KEYCLOAK_URL and REDIRECT_URI now read from
PUBLIC_KEYCLOAK_URL / PUBLIC_SUPABASE_URL so they follow the same
host as the rest of the stack.
- NEW: GOTRUE_URI_ALLOW_LIST with `/auth/callback` on both the LAN
IP and localhost. Without an explicit allow-list GoTrue rejected
`redirect_to=.../auth/callback` and fell back to SITE_URL root,
stranding `?code=` at `/` and re-triggering signIn → infinite loop.
keycloak/realm-export.json: `colectivo-web` client gains LAN-IP
redirectUris and webOrigins (alongside the existing localhost
entries). Persisted + live-applied via admin API.
.gitignore: add .claude/ (per-project scheduler runtime state).
Verification
just test-all → 224 passed, 4 skipped:
34 pgTAP
140 Vitest integration + 2 skipped (Realtime presence, upstream bug)
11 Vitest unit
39 Playwright + 2 skipped (mobile-swipe touch, WebKit-only)
Phone sanity check: open http://192.168.1.167:5173 on a LAN device,
log in as a seed user, full RLS-respecting session.
Caveats
LAN IP (192.168.1.167) is DHCP-assigned — if it rotates, rerun the
Keycloak admin API update (realm-export.json needs a new entry) and
update the three PUBLIC_* URLs. Consider a Tailscale magic-DNS name
as a stable replacement for the prod-deploy sprint.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The duplicate design/DESIGN.md was removed; the authoritative "Monolith
Editorial" spec lives at design/slate_collective/DESIGN.md. Update
CLAUDE.md, README.md, and plan/fase-5-mobile-ux.md accordingly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add plan/fase-5-mobile-ux.md with 7-chunk breakdown:
5.0 mobile tests (Playwright mobile-shell/masthead/swipe-delete + Vitest helpers)
5.1 responsive shell (MobileTopBar + BottomTabBar + MobileDrawer)
5.2 ScreenMasthead + px-4/md:px-6 pass
5.3 /lists mobile cards
5.4 /lists/[id] rows + red-zone swipe-delete
5.5 undoQueue store + UndoToast
5.6 scrollable frequency chips
5.7 tasks/notes/search shell + masthead
5.Z verify 211 + new M-series green
- Explicit gap analysis vs. current app + deliberate exclusions
(no priority badges, no voice search, no fake meta like "Dairy · Weekly")
- Index design/ directory: Stitch mockups per screen (mobile + desktop variants),
plus the "Monolith Editorial" DESIGN.md directing tonal shifts, masthead
typography, glassmorphism, and the no-line rule
- Relocate DESIGN.md under design/slate_collective/DESIGN.md (old entry point) —
the current design direction lives at design/DESIGN.md
- Reference design/ from CLAUDE.md + add Fase 5 to README phase index
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Documents the flake fix from 3afc911 so future contributors don't
accidentally drop the timeout back to 5s. Cross-referenced with the
`project_realtime_config` memory, which has the full rationale.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Under full `just test-all` load the Phoenix socket can take a beat to
propagate a new subscription's filter to the backend before a mutation
fires, so R-01 occasionally timed out at 5s. Passing runs still resolve
instantly on event arrival — this only widens the flake window.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fase 2b closes the critical-path product differentiator. Two sessions on the
same list see each other's changes in real time, mutations survive network
drops and sync when reconnected, and a dedicated full-screen shopping view
optimises the in-store experience.
2b.1 Realtime
- `$lib/stores/realtimeSync.ts` wraps `postgres_changes` with an `applyItemEvent`
reducer (INSERT/UPDATE/DELETE → next items[]) and a `subscribeToList` helper
that awaits the SUBSCRIBED handshake before returning
- `/lists/[id]` subscribes in onMount, unsubscribes in onDestroy. Uses a
`pendingTempIds` set to deduplicate own-mutation echoes against optimistic
rows (matches by name+created_by+sort_order)
- Client-generated UUIDs for new inserts make the path idempotent: if the
server response is lost and we retry, the second POST hits PK-conflict
which the queue treats as success
- CHECKED section div now carries role="listitem" so Playwright locators
follow the item across sections
2b.2 Offline queue
- `$lib/sync/queue.ts` — SyncQueue class backed by IndexedDB (idb), FIFO flush,
MAX_ATTEMPTS=5 retry budget, PK-conflict-as-success short-circuit
- `$lib/sync/index.ts` — app-wide singleton, window.online listener flushes
automatically, hydrateSyncState() at page load restores pendingOpsCount
- `$lib/stores/syncStatus.ts` — derived store (offline | syncing | synced)
tracking navigator.onLine + queue depth
- SyncBanner component renders the offline/syncing indicator in the detail
and session views
- handleAdd enqueues on failure instead of reverting, so an offline mutation
keeps its optimistic row and syncs on reconnect
2b.3 Modo Compra
- `/lists/[id]/session/+page.svelte` — full-screen overlay (fixed inset-0
z-50) that covers the sidebar while keeping the normal auth routing.
56×56 toggles, flip animation shuffling items between TO BUY / CHECKED,
Finish Shopping confirmation modal → completeList → goto('/lists')
- Link from `/lists/[id]` header (data-testid=start-session) with the
new `list_start_session` message
Testing infra
- apps/web gets its own Vitest config (jsdom + fake-indexeddb/auto) and a
new `pnpm test:unit` script. `just test-all` now chains pgTAP → integration
→ unit → e2e so a single command is the gate.
- packages/test-utils/tests/sync-queue.test.ts (placeholder scaffold) removed —
replaced by `apps/web/src/lib/sync/queue.test.ts` co-located with the module
Totals: 103 tests green
16 pgTAP
59 Vitest integration (in packages/test-utils)
6 Vitest unit (in apps/web — the SyncQueue)
22 Playwright E2E (5 auth + 4 lists + 6 items + 2 realtime + 2 offline + 3 session)
2 skipped (realtime-presence — upstream bug, unchanged)
Documented in plan/fase-2b and CLAUDE.md.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extend the 2b.0 red-phase coverage: presence, RLS isolation, offline-queue
contract, and Playwright scaffolds for the shopping-session UX. Only the
presence test is blocked on an upstream Realtime bug (documented below); all
other Realtime paths are proven end-to-end.
Tests
- realtime-postgres-changes.test.ts: now uses afterEach socket disconnect
(Vitest singleFork kept Phoenix sockets alive between files, leaking state)
- realtime-isolation.test.ts (new): R-I-01 David (guest, same collective)
receives events; R-I-02 Eva (non-member) receives NONE — RLS is enforced
server-side per subscribed JWT
- realtime-presence.test.ts (new, describe.skip): two-client roster + leave
assertions ready, gated on upstream bug
- sync-queue.test.ts (new, describe.skip): 7 placeholders pinning the
apps/web/src/lib/sync/queue.ts contract (Q-01..Q-04 enqueue / in-order
flush / retry; F-01..F-03 online-event flush + last-write-wins)
- apps/web/tests/e2e/{session,realtime,offline}.test.ts (new, describe.skip):
S-01..S-03, R-E-01, R-E-02, O-01, O-02 pinning the Modo Compra UI contract
- vitest.config.ts: fileParallelism=false, drop singleFork so each file gets
a fresh worker fork — prevents RealtimeClient singleton leakage
Infra
- db-init/00-role-passwords.sh: pre-create `realtime` schema with
AUTHORIZATION supabase_admin. Realtime's per-tenant migrator creates tables
INSIDE the schema but does not create the schema itself; without this the
first tenant connect fails with "schema realtime does not exist"
- docker-compose.dev.yml: adopt the upstream supabase/supabase Realtime env
shape — SEED_SELF_HOST=true + RUN_JANITOR=true + RLIMIT_NOFILE=10000 +
drop the custom `command: eval seeds` (that bypasses the normal supervisor
startup and was observed to cause GenServer crashes under load).
Version pinned to v2.76.5 to match the official docker-compose.
Known upstream bug
- Realtime v2.76.5 and v2.83.0 both crash on presence_diff:
`(UndefinedFunctionError) RealtimeChannel.handle_out/3 is undefined`.
postgres_changes + isolation unaffected (they don't traverse handle_out).
Presence tests stay `describe.skip` until a fixed upstream is released.
Totals: 59 Vitest passed (+ 9 skipped awaiting implementation/upstream),
16 pgTAP, 15 Playwright (+ 7 E2E scaffolded for 2b UI) = 90 green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Enable postgres_changes broadcasts on shopping_items / shopping_lists and prove
the path end-to-end with Vitest Realtime tests and a pgTAP configuration check.
Infra changes that were silently blocking events
- Migration 007: shopping_items + shopping_lists added to supabase_realtime
publication with REPLICA IDENTITY FULL (UPDATE/DELETE payloads need the full
row so list_id-based filters work and RLS can evaluate DELETE against OLD)
- Realtime service: DB_USER=supabase_admin (superuser; supabase_replication_admin
lacks CREATE on the `realtime` schema that the service auto-creates per tenant)
- Realtime service: SELF_HOST_TENANT_NAME=realtime so the seed tenant name
matches the default supabase-js resolves for localhost URLs (was realtime-dev
→ TenantNotFound)
Tests
- pgTAP 004_realtime_publication.sql — P-01..P-04: publication membership and
REPLICA IDENTITY FULL for both shopping tables
- Vitest realtime-postgres-changes.test.ts — R-01 INSERT broadcast,
R-02 UPDATE carries full row, R-03 list_id filter isolates events
- test-utils realtime-helpers.ts — subscribePostgresChanges() returns a
waitFor(predicate, timeout) helper that captures the SUBSCRIBED handshake
before returning so mutations issued right after are not lost
- createClientAs now calls realtime.setAuth(token) so the server evaluates
RLS against the test user's JWT
- Justfile test-db now globs supabase/tests/*.sql so new pgTAP files are picked
up automatically
Totals: 54→57 Vitest, 12→16 pgTAP, 15 Playwright = 88 tests green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a 4-layer test stack covering RLS, triggers, and UI flows for Fases 0–2a,
then restructure every plan file so future fases start with tests and end with
a verification gate.
Test suite
- packages/test-utils: Vitest integration tests signing HS256 JWTs via jose so
each test acts as a specific seed user (createClientAs + createAdminClient)
- supabase/tests: pgTAP for accept_invitation(), item_frequency trigger, and
promote-on-admin-leave; each file self-installs pgtap extension
- apps/web/tests: Playwright E2E with live Keycloak login per test (storageState
caching doesn't rehydrate Supabase's session state reliably)
- just test-all chains the three suites; test-db forwards POSTGRES_PASSWORD
as PGPASSWORD with ON_ERROR_STOP=1 so failures abort the chain
Supabase auth gotcha
- PostgREST queries inside onAuthStateChange deadlock on GoTrue's navigator.locks
auth lock (getAccessToken → getSession → initializePromise waits on the same
lock that's held during event dispatch). Fix is two defenses: a pass-through
lock in $lib/supabase, and a setTimeout(0) defer in root +layout.svelte to
push loadUserCollectives out of the callback's microtask chain. Either alone
is insufficient; both together unblock the Playwright suite.
Env key rotation
- apps/web/.env.development had a stale demo anon key signed with a different
secret than root .env; Vite inlined that into the browser bundle so Kong (which
uses the root .env value) rejected every request with 401. Aligned the two
files and added a memory entry to flag this for the next rotation.
Plan restructure (TDD)
- Every fase now opens with X.0 Tests primero and closes with X.Z Verificación
final. Completed fases (0, 1, 2a) show the pattern retroactively with the
tests that currently cover them; pending fases (2b, 3, 4) list the tests to
write before implementation.
Docs
- CLAUDE.md status line reports 54 + 12 + 15 = 81 green tests, adds gotchas
#11 (auth-lock deadlock) and #12 (no storageState caching)
- README.md adds a TDD methodology section and the test-all command
- .gitignore excludes Playwright's generated reports and auth state
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove all 1px border separators (sidebar border-r, page header border-b,
section border-t, member list divide-y, invite card border). Separation is
now achieved via background color shifts and whitespace per the "No-Line" rule.
- Fix sidebar: bg-surface-raised vs bg-background creates tonal separation.
- Correct dark background to #020617 (slate-950) per spec.
- Add surface-container-low, text-primary/secondary/muted, destructive tokens.
- Fix CSS variable color space: all tokens are RGB triplets; switch Tailwind
config and app.css from hsl() to rgb() to prevent yellow/warm color bleed.
- Replace emoji nav icons with Lucide icons at 16px / 1.5 stroke-width.
- Apply Masthead typography (uppercase Label-MD + Title-LG) to all page headers
and section labels across lists, tasks, notes, search, settings, manage.
- Destructive action (sign out) uses bg-destructive per spec.
- Add `just serve` command: builds and runs the prod Node server at :3000
against the dev Docker stack for production build testing.
- Add nav_collective i18n key (en + es).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Auth: `getSession()` in a SvelteKit load function races with Supabase's async
localStorage init and can return null for a valid session, triggering a spurious
login() redirect. Moved auth redirect to (app)/+layout.svelte via $effect, which
correctly waits for onAuthStateChange to fire. Also fixed collective data never
loading on page refresh (INITIAL_SESSION event, not SIGNED_IN).
PWA: SvelteKit blocks Workbox imports in src/service-worker.ts, preventing
@vite-pwa/sveltekit from finding the compiled SW output. Switched to generateSW
strategy (plugin auto-generates the SW). Custom SW deferred to Fase 2b using
src/sw.ts (a filename SvelteKit does not intercept).
Docs: added gotchas 6–8 to CLAUDE.md covering both root causes so they are not
reintroduced. Updated plan/fase-2b with the sw.ts workaround note.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extend PATH with ~/.cargo/bin, ~/.local/bin, linuxbrew before checking just
- Install just to ~/.local/bin (no sudo) instead of /usr/local/bin
- /etc/hosts and supabase/migrations chown: use sudo -n so they fail cleanly
with a human-readable instruction instead of hanging on a password prompt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Automates the full first-time setup in one command (also safe to re-run):
- Checks docker/pnpm; installs just if missing
- Adds 127.0.0.1 keycloak to /etc/hosts (sudo)
- Generates .env with dev JWT tokens computed from the dev secret
- Fixes supabase/migrations permissions if root-owned
- Starts the Docker stack and waits for postgres/kong/keycloak
- Applies SQL migrations with tracking table (won't re-apply)
- Seeds the database (no-op if already seeded)
- Compiles Paraglide i18n output
Also adds `just setup` recipe and fixes the dev anon key in .env.development.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- JWT strategy: Option B (GoTrue as OIDC proxy, not direct Keycloak JWT)
- Self-registration enabled in Keycloak realm
- Multiple collectives per user with sidebar switcher
- Invitations are link-only (no email/Resend integration)
- Avatar upload uses in-browser crop (cropperjs) at 1:1 ratio
- Removed Edge Functions from plan; invitation logic handled in SvelteKit server actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>