fix: generateId() fallback for insecure-context origins
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>
This commit is contained in:
@@ -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<ShoppingList | null> {
|
||||
const tempId = crypto.randomUUID();
|
||||
const tempId = generateId();
|
||||
const optimistic: ShoppingList = {
|
||||
id: tempId,
|
||||
collective_id: collectiveId,
|
||||
|
||||
@@ -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<TaskList | null> {
|
||||
const tempId = crypto.randomUUID();
|
||||
const tempId = generateId();
|
||||
const optimistic: TaskList = {
|
||||
id: tempId,
|
||||
collective_id: collectiveId,
|
||||
|
||||
@@ -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<void> {
|
||||
const full: PendingOp = {
|
||||
...op,
|
||||
id: crypto.randomUUID(),
|
||||
id: generateId(),
|
||||
createdAt: Date.now(),
|
||||
attempts: 0
|
||||
} as PendingOp;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
49
apps/web/src/lib/utils/id.test.ts
Normal file
49
apps/web/src/lib/utils/id.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
33
apps/web/src/lib/utils/id.ts
Normal file
33
apps/web/src/lib/utils/id.ts
Normal file
@@ -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('')}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
64
apps/web/tests/e2e/insecure-context.test.ts
Normal file
64
apps/web/tests/e2e/insecure-context.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* INS-series — insecure-context regression
|
||||
*
|
||||
* When the app is served over a non-secure origin (http://<LAN-IP>, 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user