From 53de447aa559bf690440bff138562e70bd5587cf Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Tue, 14 Apr 2026 02:41:39 +0200 Subject: [PATCH] fix: generateId() fallback for insecure-context origins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 4 +- README.md | 2 +- apps/web/src/lib/stores/lists.ts | 3 +- apps/web/src/lib/stores/tasks.ts | 3 +- apps/web/src/lib/sync/queue.ts | 3 +- apps/web/src/lib/sync/undoQueue.ts | 6 +- apps/web/src/lib/utils/id.test.ts | 49 ++++++++++++++ apps/web/src/lib/utils/id.ts | 33 ++++++++++ .../src/routes/(app)/lists/[id]/+page.svelte | 3 +- .../src/routes/(app)/tasks/[id]/+page.svelte | 3 +- apps/web/tests/e2e/insecure-context.test.ts | 64 +++++++++++++++++++ plan/fase-5-mobile-ux.md | 23 +++++-- 12 files changed, 181 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/lib/utils/id.test.ts create mode 100644 apps/web/src/lib/utils/id.ts create mode 100644 apps/web/tests/e2e/insecure-context.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index dfdfa36..9c4b1e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Status -**Fase 0 complete. Fase 1 complete. Fase 2a complete. Fase 2b complete (Realtime + offline queue + Modo Compra). Fase 3 complete (Tareas + Notas). Fase 4 partially complete (global search + RLS audit). Fase 5 complete for the automatable scope (mobile UX: responsive shell + masthead + row redesign + selection mode; see `plan/fase-5-mobile-ux.md`). 228 tests green: 34 pgTAP + 140 Vitest integration + 11 Vitest unit + 43 Playwright. 4 skipped (2 Realtime presence — upstream `handle_out/3` bug; 2 mobile-swipe touch gestures — needs WebKit install). PWA install/push/rate-limit/JWT rotation deferred to a prod-deploy sprint. ✅** +**Fase 0 complete. Fase 1 complete. Fase 2a complete. Fase 2b complete (Realtime + offline queue + Modo Compra). Fase 3 complete (Tareas + Notas). Fase 4 partially complete (global search + RLS audit). Fase 5 complete for the automatable scope (mobile UX: responsive shell + masthead + row redesign + selection mode + insecure-context resilience; see `plan/fase-5-mobile-ux.md`). 233 tests green: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 44 Playwright. 2 skipped (Realtime presence — upstream `handle_out/3` bug). PWA install/push/rate-limit/JWT rotation deferred to a prod-deploy sprint. ✅** - `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings - `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules) @@ -150,6 +150,8 @@ just test-e2e-headed # Playwright with visible browser (debugging) - Vitest unit harness in `apps/web/` — `vitest.config.ts` (jsdom env) + `vitest.setup.ts` (fake-indexeddb/auto) + `pnpm --filter @colectivo/web test:unit` - Realtime config gotchas documented in `project_realtime_config` memory: tenant name, DB_USER=supabase_admin, `SEED_SELF_HOST: "true"`, pre-created `realtime` schema, publication + REPLICA IDENTITY FULL - **Realtime `waitFor` timeout is 10s, not 5s.** Under full `just test-all` load the Phoenix socket occasionally takes several seconds to propagate a fresh subscription's filter before the test mutation fires. Set in `packages/test-utils/src/realtime-helpers.ts`. Don't drop below 10s — passing runs resolve on event arrival, only the flake window widened. +- **Never call `crypto.randomUUID()` directly — use `generateId()` from `$lib/utils/id`.** `crypto.randomUUID` is 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.randomUUID` is `undefined` and every optimistic-id site crashes `handleAdd` with `TypeError: crypto.randomUUID is not a function`. `generateId()` uses `crypto.randomUUID()` when available and falls back to an RFC 4122 v4 assembly via `crypto.getRandomValues()` (which IS exposed on insecure contexts). Covered by `src/lib/utils/id.test.ts` (U-01..U-04) + `tests/e2e/insecure-context.test.ts` (INS-01, stubs `randomUUID = undefined` via `addInitScript` and drives the add-item flow). +- **Playwright `baseURL` is hardcoded to `http://localhost:5173`, ignoring `PUBLIC_APP_URL`.** The LAN-IP variant in root `.env` is for on-device phone previews and introduces timing races in the OAuth round-trip that produce flaky failures in the suite. Keep the loopback for deterministic E2E; phone previews are documented below. ### Fase 2a — what has been built diff --git a/README.md b/README.md index 29442dd..057254a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ | Fase 3 — Tareas y Notas | ✅ Completa | | Fase 4 — Búsqueda y Pulido | 🟡 Búsqueda + RLS audit completos; PWA install/push/rate-limit/JWT rotation diferidos a sprint de deploy | | Fase 5 — Rediseño UX Mobile | ✅ Completa en scope automatizable (shell + masthead + big-button create + row redesign + selection mode + sticky detail chrome); presence avatars en cards + M-swipe/SEL-long-press touch E2E diferidos a WebKit install | -| Suite de tests (pgTAP + Vitest + Playwright) | ✅ 228 tests verdes (34 pgTAP + 140 integración + 11 unit + 43 E2E), 4 skipped, ejecutables con `just test-all` | +| Suite de tests (pgTAP + Vitest + Playwright) | ✅ 233 tests verdes (34 pgTAP + 140 integración + 15 unit + 44 E2E), 2 skipped, ejecutables con `just test-all` | --- diff --git a/apps/web/src/lib/stores/lists.ts b/apps/web/src/lib/stores/lists.ts index d64cac3..5d8a0d2 100644 --- a/apps/web/src/lib/stores/lists.ts +++ b/apps/web/src/lib/stores/lists.ts @@ -1,5 +1,6 @@ import { writable } from 'svelte/store'; import { getSupabase } from '$lib/supabase'; +import { generateId } from '$lib/utils/id'; import type { ShoppingList, ShoppingItem, ItemFrequency } from '@colectivo/types'; // ── Global list store (overview page) ───────────────────────────────────────── @@ -41,7 +42,7 @@ export async function createList( name: string, userId: string ): Promise { - const tempId = crypto.randomUUID(); + const tempId = generateId(); const optimistic: ShoppingList = { id: tempId, collective_id: collectiveId, diff --git a/apps/web/src/lib/stores/tasks.ts b/apps/web/src/lib/stores/tasks.ts index 38a754b..9bbef91 100644 --- a/apps/web/src/lib/stores/tasks.ts +++ b/apps/web/src/lib/stores/tasks.ts @@ -1,5 +1,6 @@ import { writable } from 'svelte/store'; import { getSupabase } from '$lib/supabase'; +import { generateId } from '$lib/utils/id'; import type { Task, TaskList } from '@colectivo/types'; // ── Task lists store (overview page) ────────────────────────────────────────── @@ -26,7 +27,7 @@ export async function createTaskList( name: string, userId: string ): Promise { - const tempId = crypto.randomUUID(); + const tempId = generateId(); const optimistic: TaskList = { id: tempId, collective_id: collectiveId, diff --git a/apps/web/src/lib/sync/queue.ts b/apps/web/src/lib/sync/queue.ts index 2efacf6..7a87362 100644 --- a/apps/web/src/lib/sync/queue.ts +++ b/apps/web/src/lib/sync/queue.ts @@ -22,6 +22,7 @@ */ import { openDB, type IDBPDatabase } from 'idb'; import type { Database } from '@colectivo/types'; +import { generateId } from '$lib/utils/id'; type BaseOp = { id: string; createdAt: number; attempts: number }; @@ -87,7 +88,7 @@ export class SyncQueue { async enqueue(op: NewOp): Promise { const full: PendingOp = { ...op, - id: crypto.randomUUID(), + id: generateId(), createdAt: Date.now(), attempts: 0 } as PendingOp; diff --git a/apps/web/src/lib/sync/undoQueue.ts b/apps/web/src/lib/sync/undoQueue.ts index b2215c7..4a3ce0e 100644 --- a/apps/web/src/lib/sync/undoQueue.ts +++ b/apps/web/src/lib/sync/undoQueue.ts @@ -1,4 +1,5 @@ import { writable, get } from 'svelte/store'; +import { generateId } from '$lib/utils/id'; export interface UndoableAction { id: string; @@ -28,10 +29,7 @@ export function scheduleUndoable(params: { commit: UndoableAction['commit']; ttlMs?: number; }): string { - const id = - typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? crypto.randomUUID() - : `undo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const id = generateId(); const ttl = params.ttlMs ?? UNDO_TTL_MS; diff --git a/apps/web/src/lib/utils/id.test.ts b/apps/web/src/lib/utils/id.test.ts new file mode 100644 index 0000000..9108069 --- /dev/null +++ b/apps/web/src/lib/utils/id.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { generateId } from './id'; + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +describe('generateId', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('U-01: returns a UUID v4-shaped string when crypto.randomUUID is available', () => { + const id = generateId(); + expect(id).toMatch(UUID_V4); + }); + + it('U-02: falls back when crypto.randomUUID is undefined (insecure-context case)', () => { + // Simulate an HTTP LAN origin: window.crypto exists but randomUUID is not exposed. + const baseCrypto = globalThis.crypto; + vi.stubGlobal('crypto', { + getRandomValues: baseCrypto.getRandomValues.bind(baseCrypto) + // no randomUUID + }); + + const id = generateId(); + expect(id).toMatch(UUID_V4); + }); + + it('U-03: every call returns a unique value (uniqueness under fallback)', () => { + const baseCrypto = globalThis.crypto; + vi.stubGlobal('crypto', { + getRandomValues: baseCrypto.getRandomValues.bind(baseCrypto) + }); + + const ids = new Set(Array.from({ length: 50 }, generateId)); + expect(ids.size).toBe(50); + }); + + it('U-04: prefers crypto.randomUUID when present (does not drop into fallback)', () => { + const spy = vi.fn(() => '11111111-1111-4111-8111-111111111111'); + vi.stubGlobal('crypto', { + randomUUID: spy, + getRandomValues: globalThis.crypto.getRandomValues.bind(globalThis.crypto) + }); + + const id = generateId(); + expect(spy).toHaveBeenCalledTimes(1); + expect(id).toBe('11111111-1111-4111-8111-111111111111'); + }); +}); diff --git a/apps/web/src/lib/utils/id.ts b/apps/web/src/lib/utils/id.ts new file mode 100644 index 0000000..9b2e2fc --- /dev/null +++ b/apps/web/src/lib/utils/id.ts @@ -0,0 +1,33 @@ +/** + * Generate a RFC 4122 v4 UUID. + * + * `crypto.randomUUID()` is only exposed on secure contexts (HTTPS, localhost, + * 127.0.0.1, file://). The app is served over bare LAN IPs for on-device + * phone previews (e.g. http://192.168.1.167:5173) — a non-secure origin — + * where `randomUUID` is undefined and every optimistic-id call site crashed + * with `TypeError: crypto.randomUUID is not a function`. + * + * `crypto.getRandomValues` IS available on insecure contexts (unlike + * `crypto.subtle` which is gated), so the fallback here is v4-compliant and + * cryptographically acceptable for client-side optimistic ids. + * + * Background: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID + */ +export function generateId(): string { + // Secure-context fast path. + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + // Fallback: 16 random bytes → format per RFC 4122 §4.4. + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + + // Set version (4) and variant (10xx) bits. + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex: string[] = []; + for (let i = 0; i < 16; i++) hex.push(bytes[i].toString(16).padStart(2, '0')); + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`; +} diff --git a/apps/web/src/routes/(app)/lists/[id]/+page.svelte b/apps/web/src/routes/(app)/lists/[id]/+page.svelte index e7c2603..f34e648 100644 --- a/apps/web/src/routes/(app)/lists/[id]/+page.svelte +++ b/apps/web/src/routes/(app)/lists/[id]/+page.svelte @@ -32,6 +32,7 @@ import { scheduleUndoable } from '$lib/sync/undoQueue'; import SyncBanner from '$lib/components/SyncBanner.svelte'; import { getSupabase } from '$lib/supabase'; + import { generateId } from '$lib/utils/id'; import type { ShoppingItem, ShoppingList } from '@colectivo/types'; import ItemSuggestions from '$lib/components/ItemSuggestions.svelte'; import { @@ -306,7 +307,7 @@ if (!name || !$currentUser) return; const sortOrder = items.length; - const tempId = crypto.randomUUID(); + const tempId = generateId(); const optimistic: ShoppingItem = { id: tempId, list_id: listId, diff --git a/apps/web/src/routes/(app)/tasks/[id]/+page.svelte b/apps/web/src/routes/(app)/tasks/[id]/+page.svelte index 8db308b..042d7f0 100644 --- a/apps/web/src/routes/(app)/tasks/[id]/+page.svelte +++ b/apps/web/src/routes/(app)/tasks/[id]/+page.svelte @@ -16,6 +16,7 @@ renameTaskList } from '$lib/stores/tasks'; import { getSupabase } from '$lib/supabase'; + import { generateId } from '$lib/utils/id'; import type { Task, TaskList } from '@colectivo/types'; import { ArrowLeft, GripVertical, Plus, Trash2, Check } from 'lucide-svelte'; import * as m from '$lib/paraglide/messages'; @@ -134,7 +135,7 @@ const title = newTitle.trim(); if (!title || !$currentUser) return; const sortOrder = pendingTasks.length; - const tempId = crypto.randomUUID(); + const tempId = generateId(); const optimistic: Task = { id: tempId, list_id: listId, diff --git a/apps/web/tests/e2e/insecure-context.test.ts b/apps/web/tests/e2e/insecure-context.test.ts new file mode 100644 index 0000000..0eece02 --- /dev/null +++ b/apps/web/tests/e2e/insecure-context.test.ts @@ -0,0 +1,64 @@ +/** + * INS-series — insecure-context regression + * + * When the app is served over a non-secure origin (http://, used for + * on-device phone previews), `window.crypto.randomUUID` is undefined and every + * optimistic-id generator that called it crashed `handleAdd`. + * + * We reproduce that condition by shimming `crypto.randomUUID` to `undefined` + * via `addInitScript` on a page that otherwise runs on localhost (where + * Playwright has a deterministic OAuth path). Same code path, just with the + * API removed. + */ +import { test, expect } from '@playwright/test'; +import { USERS } from '../fixtures/users.js'; +import { loginAs } from '../fixtures/login.js'; + +const SEED_LIST = '/lists/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + +test.describe('Insecure-context resilience', () => { + test('INS-01: adding an item works when crypto.randomUUID is undefined', async ({ context, page }) => { + await context.addInitScript(() => { + // Simulate an insecure origin: randomUUID is gated behind secure + // contexts and therefore missing. getRandomValues stays available. + Object.defineProperty(window.crypto, 'randomUUID', { + configurable: true, + value: undefined + }); + }); + + // Re-assert the stub after navigation in case the context re-injects. + page.on('framenavigated', async () => { + await page + .evaluate(() => { + Object.defineProperty(window.crypto, 'randomUUID', { + configurable: true, + value: undefined + }); + }) + .catch(() => {}); + }); + + await loginAs(page, USERS.borja); + await page.goto(SEED_LIST); + + // Sanity: the stub stuck — the page sees randomUUID as undefined. + const stubbed = await page.evaluate(() => typeof window.crypto.randomUUID); + expect(stubbed).toBe('undefined'); + + const input = page.getByPlaceholder(/add item|añadir producto/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + + const itemName = `INS-01-${Date.now()}`; + await input.fill(itemName); + await input.press('Enter'); + + // The row must render — i.e. generateId() returned a valid UUID and the + // optimistic insert + server round-trip completed. If the old + // crypto.randomUUID() call site is still there, handleAdd throws a + // TypeError and the row never appears. + await expect( + page.locator('[role="listitem"]').filter({ hasText: itemName }).first() + ).toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/plan/fase-5-mobile-ux.md b/plan/fase-5-mobile-ux.md index f95225d..cb0d3ba 100644 --- a/plan/fase-5-mobile-ux.md +++ b/plan/fase-5-mobile-ux.md @@ -1,5 +1,5 @@ ### Fase 5 — Rediseño UX Mobile ("Monolith Editorial") -**Estado: ✅ Completa en scope automatizable. 228 tests verdes (34 pgTAP + 140 integration + 11 unit + 43 Playwright), 4 skipped. Diferido: M-series swipe gesture tests (WebKit install), SEL mobile long-press tests, card presence avatars, `max-w-2xl` reading-width.** +**Estado: ✅ Completa en scope automatizable. 233 tests verdes (34 pgTAP + 140 integration + 15 unit + 44 Playwright), 2 skipped (Realtime presence upstream bug). Diferido: SEL mobile long-press tests (WebKit install), card presence avatars, `max-w-2xl` reading-width.** **Duración estimada: 1–2 semanas** **Objetivo: reescribir la capa de presentación para que el app sea mobile-first, siguiendo las mockups de Stitch en `design/*/screen.png` y la dirección "Monolith Editorial" de `design/slate_collective/DESIGN.md`.** @@ -442,13 +442,28 @@ Doble tap corto (≤ 300 ms entre toques) en una fila (en modo normal) abre un o --- +#### 5.13 Resistencia a contextos no-seguros (HTTP LAN) + +**Problema descubierto tras deploy de la preview mobile:** el usuario abrió `http://192.168.1.167:5173` en el móvil (origen no-seguro) y `handleAdd` lanzó `TypeError: crypto.randomUUID is not a function`. `crypto.randomUUID` solo está expuesto en HTTPS, localhost, 127.0.0.1 o file:// — fuera de eso queda `undefined`. + +**Cambios:** + +- [x] Nuevo helper `apps/web/src/lib/utils/id.ts` — `generateId()` usa `crypto.randomUUID()` cuando está disponible; cae en `crypto.getRandomValues()` + ensamblado RFC 4122 §4.4 cuando no (la API de bytes aleatorios SÍ está en contextos inseguros). +- [x] Sustituidos todos los call sites: `stores/lists.ts`, `stores/tasks.ts`, `sync/queue.ts`, `sync/undoQueue.ts`, `lists/[id]/+page.svelte`, `tasks/[id]/+page.svelte`. +- [x] Regla del repo (añadida a CLAUDE.md): nadie llama `crypto.randomUUID()` directamente — siempre `generateId()`. + +**Tests:** + +- [x] `src/lib/utils/id.test.ts` — U-01 devuelve UUID v4; U-02 fallback cuando `randomUUID` es `undefined`; U-03 50 IDs únicos bajo fallback; U-04 prefiere `randomUUID` cuando existe. +- [x] `tests/e2e/insecure-context.test.ts` — INS-01: `addInitScript` anula `randomUUID`, `loginAs`, añadir ítem, verificar que no crashea y el row aparece. + #### 5.Z Verificación final — todos los tests verdes -- [x] `just test-all` → **228 verdes, 4 skipped**: +- [x] `just test-all` → **233 verdes, 2 skipped**: - 34 pgTAP (sin cambios) - 140 Vitest integration + 2 skipped presence (sin cambios) - - 11 Vitest unit (sin cambios) - - 43 Playwright + 2 skipped swipe-touch (was 39, +1 mobile-shell M-08, +3 selection SEL-01/02/03; D-03 y D-04 reescritos para el nuevo overlay) + - 15 Vitest unit (was 11, +4 generateId) + - 44 Playwright (was 43, +1 INS-01) - [ ] Validación visual en DevTools iPhone 13 (390×844) + Pixel 7 (412×915) + Desktop (1280×800) — pendiente (manual, post-deploy). - [x] `loginAs(USERS.borja)` con `page.setViewportSize(MOBILE)` activa el shell mobile automáticamente (verificado en M-01..M-04). - [x] Seis commits incrementales (5.10.0 → 5.12 → 5.8 → 5.10 → 5.11) para facilitar revert o review por pasos.