From 5a63e5fc4a0a1bf9fc980960f9a001730d3f56dc Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 13 Apr 2026 16:09:32 +0200 Subject: [PATCH] feat(dev): expose full auth stack on LAN IP for on-device preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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) --- .gitignore | 3 +++ apps/web/.env.development | 6 +++--- apps/web/playwright.config.ts | 5 ++++- apps/web/tests/e2e/items.test.ts | 8 ++++++-- apps/web/tests/fixtures/login.ts | 33 +++++++++++++++++++++++++++----- apps/web/vite.config.ts | 8 +++++++- infra/docker-compose.dev.yml | 21 +++++++++++++++----- keycloak/realm-export.json | 6 +++++- 8 files changed, 72 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 298d097..103fd45 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ infra/volumes/ apps/web/playwright-report/ apps/web/test-results/ apps/web/tests/.auth/ + +# Claude Code per-project runtime state (schedulers etc.) +.claude/ diff --git a/apps/web/.env.development b/apps/web/.env.development index af75edb..96e7608 100644 --- a/apps/web/.env.development +++ b/apps/web/.env.development @@ -5,9 +5,9 @@ # that Kong + GoTrue + PostgREST use (see root .env). The repo-wide .env is the # authoritative source; this file exists so non-sensitive PUBLIC_* values can be # versioned for new contributors. If you rotate JWT_SECRET, update this file too. -PUBLIC_SUPABASE_URL=http://localhost:8001 +PUBLIC_SUPABASE_URL=http://192.168.1.167:8001 PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 -PUBLIC_KEYCLOAK_URL=http://keycloak:8080 +PUBLIC_KEYCLOAK_URL=http://192.168.1.167:8080 PUBLIC_KEYCLOAK_REALM=colectivo PUBLIC_KEYCLOAK_CLIENT_ID=colectivo-web -PUBLIC_APP_URL=http://localhost:5173 +PUBLIC_APP_URL=http://192.168.1.167:5173 diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 3c75e4e..33372eb 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -14,7 +14,10 @@ export default defineConfig({ reporter: [['html', { open: 'never' }], ['list']], use: { - baseURL: 'http://localhost:5173', + // Match PUBLIC_APP_URL so the OAuth round-trip (Keycloak → GoTrue → SPA) + // lands on the same origin the test started from. When PUBLIC_APP_URL is + // a LAN IP (for phone/tablet previews) tests continue to work. + baseURL: process.env.PUBLIC_APP_URL ?? 'http://localhost:5173', trace: 'on-first-retry', screenshot: 'only-on-failure', // Load anon storage state by default; individual tests override as needed diff --git a/apps/web/tests/e2e/items.test.ts b/apps/web/tests/e2e/items.test.ts index 22c2eda..8f55fa2 100644 --- a/apps/web/tests/e2e/items.test.ts +++ b/apps/web/tests/e2e/items.test.ts @@ -85,7 +85,7 @@ test.describe('Shopping items — member (Borja)', () => { const itemName = `Delete-me-${Date.now()}`; await nameInput.fill(itemName); await nameInput.press('Enter'); - await expect(page.getByText(itemName)).toBeVisible({ timeout: 5_000 }); + await expect(itemRow(page, itemName)).toBeVisible({ timeout: 5_000 }); const row = itemRow(page, itemName); await row.hover(); @@ -95,7 +95,11 @@ test.describe('Shopping items — member (Borja)', () => { // button is the one with `sm:flex` — target it via class. await row.locator('button.sm\\:flex[aria-label="Delete"]').click(); - await expect(page.getByText(itemName)).not.toBeVisible({ timeout: 5_000 }); + // After Fase 5.5 the delete goes through the undo queue: the row is + // removed immediately, but a toast briefly shows "Deleted " — that + // substring matches `getByText(itemName)`. Scope the assertion to the + // item list (role="listitem") so the toast is ignored. + await expect(itemRow(page, itemName)).not.toBeVisible({ timeout: 5_000 }); }); test('D-06: frequency suggestions render below the add-item input', async ({ page }) => { diff --git a/apps/web/tests/fixtures/login.ts b/apps/web/tests/fixtures/login.ts index 64a3029..c971185 100644 --- a/apps/web/tests/fixtures/login.ts +++ b/apps/web/tests/fixtures/login.ts @@ -18,12 +18,12 @@ export async function loginAs(page: Page, user: (typeof USERS)[keyof typeof USER await page.fill('#password', user.password); await page.click('#kc-login'); - // Wait until we're redirected OFF the callback page. The root layout's - // post-login routing sends the user to /lists (or /onboarding) only after - // the deferred loadUserCollectives call resolves, so we can't call the - // login done until we've left /auth/callback. + // Wait until we're redirected OFF the callback page. Origin depends on the + // PUBLIC_APP_URL the stack is configured with (localhost for plain dev; LAN + // IP like http://192.168.1.167:5173 when phones are in the loop). + const appOrigin = new URL(process.env.PUBLIC_APP_URL ?? 'http://localhost:5173').origin; await page.waitForURL( - (url) => url.origin === 'http://localhost:5173' && !url.pathname.startsWith('/auth/callback'), + (url) => url.origin === appOrigin && !url.pathname.startsWith('/auth/callback'), { timeout: 20_000 } ); await page.waitForFunction( @@ -34,4 +34,27 @@ export async function loginAs(page: Page, user: (typeof USERS)[keyof typeof USER null, { timeout: 15_000 } ); + + // Wait for `$currentCollective` to populate — any test that creates rows + // needs this, because `handleCreate` silently returns when collective is + // null. On localhost the round-trip was fast enough that races were rare, + // but over LAN IP the extra latency exposes the race. We detect it by the + // sidebar's collective button rendering the actual name (otherwise it + // shows the app_name fallback "Colectivo"). + await page.waitForFunction( + (appName) => { + const buttons = document.querySelectorAll('button'); + for (const b of Array.from(buttons)) { + const txt = b.textContent?.trim(); + if (txt && txt !== appName && !txt.startsWith(appName)) { + // Heuristic: any non-default button with emoji-prefixed name + // (seed collective is "Casa García-López" with emoji "🏠"). + if (/\p{Emoji}/u.test(txt)) return true; + } + } + return false; + }, + 'Colectivo', + { timeout: 10_000 } + ); } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 19a1e23..2ec3f34 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -46,6 +46,12 @@ export default defineConfig({ }) ], server: { - port: 5173 + port: 5173, + // Bind to all interfaces so the LAN (phone, tablet, other laptops on + // the same Wi-Fi) can reach the dev server at http://:5173. + // Auth flows will fail from non-laptop clients because Keycloak's + // issuer URL resolves to `keycloak:8080` only on the laptop's /etc/hosts + // — use this for layout/CSS visual checks, not for full login tests. + host: true } }); diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index bab2a9f..7305e95 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -45,6 +45,13 @@ services: GOTRUE_DB_DRIVER: postgres GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD:-postgres}@db:5432/postgres GOTRUE_SITE_URL: ${PUBLIC_APP_URL:-http://localhost:5173} + # Allow the SPA's /auth/callback subpath (where the /auth/callback +page + # calls exchangeCodeForSession). Without this GoTrue rejects the + # redirect_to query param and falls back to SITE_URL, stranding `?code=` + # at `/` instead of `/auth/callback` — which then re-triggers signIn + # and creates an infinite login loop. We also allow localhost so that + # `just test-all` runs can still hit the app through the loopback. + GOTRUE_URI_ALLOW_LIST: "http://192.168.1.167:5173/auth/callback,http://localhost:5173/auth/callback,http://localhost:3000/auth/callback" GOTRUE_JWT_SECRET: ${SUPABASE_JWT_SECRET} GOTRUE_JWT_EXP: 3600 # Allow OIDC-based user creation (Keycloak is the gatekeeper). @@ -58,11 +65,15 @@ services: GOTRUE_EXTERNAL_KEYCLOAK_SECRET: ${KEYCLOAK_CLIENT_SECRET:-gotrue-dev-secret} # openid is required so the access token is an OIDC token and the userinfo endpoint accepts it. GOTRUE_EXTERNAL_KEYCLOAK_SCOPES: "openid profile email" - # GoTrue fetches Keycloak's OIDC discovery doc at this URL (Docker-internal hostname). - # The browser also uses this hostname — add "127.0.0.1 keycloak" to /etc/hosts. - GOTRUE_EXTERNAL_KEYCLOAK_URL: http://keycloak:8080/realms/colectivo - # Keycloak redirects the browser here after login; GoTrue exchanges the code. - GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI: http://localhost:8001/auth/v1/callback + # Must be reachable from the browser (for the 302 to Keycloak's /auth + # endpoint) AND from inside this container (for the server-side code→token + # exchange). The LAN IP in PUBLIC_KEYCLOAK_URL satisfies both: Docker + # bridge routes container→host via the default gateway, then Docker's + # port-forward loops 8080 back to the Keycloak container. + GOTRUE_EXTERNAL_KEYCLOAK_URL: ${PUBLIC_KEYCLOAK_URL:-http://keycloak:8080}/realms/colectivo + # Browser-reachable URL that Keycloak 302s back to after login. Must be + # listed in the colectivo-web client's redirectUris in the realm. + GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI: ${PUBLIC_SUPABASE_URL:-http://localhost:8001}/auth/v1/callback GOTRUE_MAILER_AUTOCONFIRM: "true" # ── PostgREST ─────────────────────────────────────────────────────────────── diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index 501cb9e..d74b8b0 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -300,11 +300,15 @@ "clientAuthenticatorType": "client-secret", "secret": "gotrue-dev-secret", "redirectUris": [ + "http://192.168.1.167:5173/*", + "http://192.168.1.167:8001/auth/v1/callback", "http://localhost:3000/*", "http://localhost:5173/*", "http://localhost:8001/auth/v1/callback" ], "webOrigins": [ + "http://192.168.1.167:5173", + "http://192.168.1.167:8001", "http://localhost:3000", "http://localhost:5173", "http://localhost:8001" @@ -983,7 +987,7 @@ { "id": "a09a9441-c626-4a26-8723-475d2d4b2356", "name": "openid", - "description": "OpenID Connect scope — ensures access token includes openid scope claim for userinfo endpoint", + "description": "OpenID Connect scope \u2014 ensures access token includes openid scope claim for userinfo endpoint", "protocol": "openid-connect", "attributes": { "include.in.token.scope": "true",