Adds `theme text not null default 'system' check (theme in
('light','dark','system'))` to public.users. New users get 'system' so
the UI follows the OS preference until they opt in; existing rows
backfill via the default.
Includes pgTAP test 009 (8 assertions covering column existence/type,
default value, valid transitions, and the check constraint rejecting
unknown values). Wires the theme field into the hand-curated
database.ts type so PostgREST queries stay typed.
Also hardens `just db-types` to bail out (instead of truncating
database.ts) when the supabase CLI is missing — the recipe was silently
wiping the file on this dev machine.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>