Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')`)
Expand Down
3 changes: 2 additions & 1 deletion src/lib/components/storage/modals/upload/UploadModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down
3 changes: 2 additions & 1 deletion src/lib/storage/connection-storage.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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);
Expand Down
65 changes: 65 additions & 0 deletions src/lib/stores/tab-store.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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);
});
});
3 changes: 2 additions & 1 deletion src/lib/stores/tab-store.svelte.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -34,7 +35,7 @@ const IndexDataSchema = z.object({
type IndexData = z.infer<typeof IndexDataSchema>;

function generateId(): string {
return crypto.randomUUID();
return randomUUID();
}

function makeTab(sql: string = DEFAULT_SQL): TabState {
Expand Down
4 changes: 3 additions & 1 deletion src/lib/stores/toast.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { randomUUID } from '$lib/utils/uuid.js';

export type ToastType = 'info' | 'success' | 'warning' | 'error';

interface Toast {
Expand All @@ -9,7 +11,7 @@ interface Toast {
export const toasts = $state<Toast[]>([]);

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);
Expand Down
61 changes: 61 additions & 0 deletions src/lib/utils/uuid.spec.ts
Original file line number Diff line number Diff line change
@@ -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: <T>(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);
});
});
30 changes: 30 additions & 0 deletions src/lib/utils/uuid.ts
Original file line number Diff line number Diff line change
@@ -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('-');
}
Loading