diff --git a/AGENTS.md b/AGENTS.md index 68489856..9cc50dc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,15 @@ The project uses **pino** for structured JSON logging (server-side only). - Use snake_case field names for queryability: `request_id`, `user_id`, `module`, `duration_ms`, `status_code`, `path`, `method` - Never log tokens, credentials, or full request/session objects directly — add redaction paths to `src/lib/server/logging/redaction.ts` +### Secure Context APIs (HTTPS) + +Some Web APIs are only exposed in a **secure context** (HTTPS, or `localhost`). Over plain HTTP on any other host they are `undefined`, and calling them throws — while the page itself still returns 200, so the failure only appears in the browser console. + +- `crypto.randomUUID()` — **not available over HTTP.** Always use `randomUUID()` from `$lib/utils/uuid.js`, which falls back to `crypto.getRandomValues()`. Do not call `crypto.randomUUID()` directly in client code. +- `crypto.subtle`, service workers and the push API have the same restriction and no `getRandomValues` fallback — check support before relying on them. + +Server-side code may use `node:crypto` freely. + ### Monaco Editor - Always use **dynamic imports** to avoid SSR issues (`import('monaco-editor')`) diff --git a/src/lib/components/storage/modals/upload/UploadModal.svelte b/src/lib/components/storage/modals/upload/UploadModal.svelte index baa67985..d22a0b45 100644 --- a/src/lib/components/storage/modals/upload/UploadModal.svelte +++ b/src/lib/components/storage/modals/upload/UploadModal.svelte @@ -10,6 +10,7 @@ import { formatFileSize } from '$lib/storage/utils.js'; import { loadConnectionLocally, getConnectionHeader } from '$lib/storage/connection-storage.js'; import { uploadConcurrency } from '$lib/client/feature-flags.js'; + import { randomUUID } from '$lib/utils/uuid.js'; import UploadDropzone from './UploadDropzone.svelte'; import UploadConflictEntry from './UploadConflictEntry.svelte'; import UploadEntryStatus from './UploadEntryStatus.svelte'; @@ -76,7 +77,7 @@ function makeEntries(pairs: { file: File; relativePath: string }[]): FileEntry[] { return pairs.map(({ file, relativePath }) => ({ - id: crypto.randomUUID(), + id: randomUUID(), file, displayPath: relativePath, targetKey: buildTargetKey(relativePath), diff --git a/src/lib/storage/connection-storage.ts b/src/lib/storage/connection-storage.ts index dd7ee8b1..e09b6226 100644 --- a/src/lib/storage/connection-storage.ts +++ b/src/lib/storage/connection-storage.ts @@ -1,4 +1,5 @@ import type { z } from 'zod'; +import { randomUUID } from '$lib/utils/uuid.js'; import { StorageConnectionSchema } from './schemas.js'; /** Raw form data — `id` may be absent when creating a new connection. */ @@ -58,7 +59,7 @@ export function saveConnectionLocally(data: StoredConnection): void { c.port === data.port && c.credentials?.accessKey === data.credentials?.accessKey ); - const id = existing?.id ?? data.id ?? crypto.randomUUID(); + const id = existing?.id ?? data.id ?? randomUUID(); const connection: SavedConnection = { ...data, id }; const filtered = all.filter((c) => c.id !== id); filtered.push(connection); diff --git a/src/lib/stores/tab-store.svelte.spec.ts b/src/lib/stores/tab-store.svelte.spec.ts new file mode 100644 index 00000000..a4faa065 --- /dev/null +++ b/src/lib/stores/tab-store.svelte.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +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}$/; + +// `randomUUID` lives on `Crypto.prototype`; shadow it with an own property so +// the fallback path is exercised. +function removeRandomUUID() { + Object.defineProperty(globalThis.crypto, 'randomUUID', { + value: undefined, + configurable: true + }); +} + +function restoreRandomUUID() { + delete (globalThis.crypto as unknown as Record).randomUUID; +} + +function importTabStore() { + vi.resetModules(); + return import('$lib/stores/tab-store.svelte.js'); +} + +describe('tabStore', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + restoreRandomUUID(); + }); + + // Must stay first: the module-scope `tabStore` is built on first import and + // is never re-created, so this is the only test able to cover initialisation + // with `crypto.randomUUID` absent. `vi.resetModules()` does not re-evaluate + // Svelte-compiled modules and a cache-busting query is not resolvable at + // runtime in browser mode, so later tests necessarily reuse this singleton. + // + // Regression: over plain HTTP on a non-localhost host `crypto.randomUUID` is + // undefined, and this module-scope initialisation threw + // `TypeError: crypto.randomUUID is not a function` during hydration — /trino + // stayed blank despite a 200 response. + // + // The timeout is generous because browser-mode SvelteKit SSR init is slow + // under full-suite parallelism and occasionally races on the first import + // (surfacing as `wrapDynamicImport`); this test triggers that first import. + it('initialises in an insecure context where crypto.randomUUID is unavailable', async () => { + removeRandomUUID(); + + const { tabStore } = await importTabStore(); + + expect(tabStore.tabs).toHaveLength(1); + expect(tabStore.tabs[0]!.id).toMatch(UUID_V4); + expect(tabStore.activeTabId).toBe(tabStore.tabs[0]!.id); + }, 60_000); + + it('creates further tabs with distinct ids', async () => { + const { tabStore } = await importTabStore(); + const created = tabStore.createTab(); + + expect(created).not.toBeNull(); + expect(created!.id).toMatch(UUID_V4); + expect(new Set(tabStore.tabs.map((tab) => tab.id)).size).toBe(2); + expect(tabStore.activeTabId).toBe(created!.id); + }); +}); diff --git a/src/lib/stores/tab-store.svelte.ts b/src/lib/stores/tab-store.svelte.ts index a2a9fedd..4eacbcd8 100644 --- a/src/lib/stores/tab-store.svelte.ts +++ b/src/lib/stores/tab-store.svelte.ts @@ -1,6 +1,7 @@ import { browser } from '$app/environment'; import { z } from 'zod'; import * as m from '$lib/paraglide/messages.js'; +import { randomUUID } from '$lib/utils/uuid.js'; const MAX_TABS = 8; const INDEX_KEY = 'trino_tabs_index'; @@ -34,7 +35,7 @@ const IndexDataSchema = z.object({ type IndexData = z.infer; function generateId(): string { - return crypto.randomUUID(); + return randomUUID(); } function makeTab(sql: string = DEFAULT_SQL): TabState { diff --git a/src/lib/stores/toast.svelte.ts b/src/lib/stores/toast.svelte.ts index 7a405679..135afcda 100644 --- a/src/lib/stores/toast.svelte.ts +++ b/src/lib/stores/toast.svelte.ts @@ -1,3 +1,5 @@ +import { randomUUID } from '$lib/utils/uuid.js'; + export type ToastType = 'info' | 'success' | 'warning' | 'error'; interface Toast { @@ -9,7 +11,7 @@ interface Toast { export const toasts = $state([]); export function addToast(type: ToastType, message: string, durationMs = 5000): void { - const id = crypto.randomUUID(); + const id = randomUUID(); toasts.push({ id, type, message }); if (durationMs > 0) { setTimeout(() => removeToast(id), durationMs); diff --git a/src/lib/utils/uuid.spec.ts b/src/lib/utils/uuid.spec.ts new file mode 100644 index 00000000..59698772 --- /dev/null +++ b/src/lib/utils/uuid.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { randomUUID } from '$lib/utils/uuid.js'; + +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}$/; + +// Captured before any stubbing so the uniqueness test uses real randomness. +const realGetRandomValues = crypto.getRandomValues.bind(crypto); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('randomUUID', () => { + it('uses crypto.randomUUID when it is available', () => { + const spy = vi.fn(() => '11111111-2222-4333-8444-555555555555'); + vi.stubGlobal('crypto', { randomUUID: spy, getRandomValues: () => undefined }); + + expect(randomUUID()).toBe('11111111-2222-4333-8444-555555555555'); + expect(spy).toHaveBeenCalledOnce(); + }); + + // `crypto.randomUUID` is only exposed in secure contexts, so it is undefined + // when the app is served over plain HTTP on a non-localhost host. + it('falls back to getRandomValues when crypto.randomUUID is unavailable', () => { + vi.stubGlobal('crypto', { getRandomValues: (array: T) => array }); + + const id = randomUUID(); + + expect(id).toMatch(UUID_V4); + }); + + it('stamps version 4 and the RFC 4122 variant bits', () => { + // All-zero bytes: the version and variant nibbles must still be forced. + vi.stubGlobal('crypto', { + getRandomValues: (array: Uint8Array) => array.fill(0) + }); + + const id = randomUUID(); + + expect(id).toBe('00000000-0000-4000-8000-000000000000'); + }); + + it('sets the variant nibble to one of 8, 9, a or b', () => { + vi.stubGlobal('crypto', { + // 0xff sets every nibble, including version and variant. + getRandomValues: (array: Uint8Array) => array.fill(0xff) + }); + + const variantNibble = randomUUID().split('-')[3]![0]; + + expect(['8', '9', 'a', 'b']).toContain(variantNibble); + }); + + it('produces distinct ids across calls', () => { + vi.stubGlobal('crypto', { getRandomValues: realGetRandomValues }); + + const ids = new Set(Array.from({ length: 100 }, () => randomUUID())); + + expect(ids.size).toBe(100); + }); +}); diff --git a/src/lib/utils/uuid.ts b/src/lib/utils/uuid.ts new file mode 100644 index 00000000..e28cc7be --- /dev/null +++ b/src/lib/utils/uuid.ts @@ -0,0 +1,30 @@ +/** + * Generate a RFC 4122 version 4 UUID. + * + * `crypto.randomUUID()` is only exposed in secure contexts, so it is + * `undefined` when the app is served over plain HTTP on a non-`localhost` + * host. Calling it there throws `TypeError: crypto.randomUUID is not a + * function`, which breaks any feature that mints an ID on the client. + * + * `crypto.getRandomValues()` carries no such restriction, so fall back to + * assembling the UUID from random bytes there. The fast path is kept because + * this module is also evaluated during SSR, where Node's `crypto` provides it. + */ +export function randomUUID(): string { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10x + + const hex = Array.from(bytes, (byte) => byte.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('') + ].join('-'); +}