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 @@ -15,6 +15,7 @@
import { formatFileSize } from '$lib/storage/utils.js';
import { connectionStore } from '$lib/storage/connection-store.svelte.js';
import { uploadConcurrency } from '$lib/client/feature-flags.js';
import { randomUUID } from '$lib/utils/uuid.js';
import UploadDropzone from './UploadDropzone.svelte';
import UploadEntryStatus from './UploadEntryStatus.svelte';
import ConflictEntry from '../shared/ConflictEntry.svelte';
Expand Down Expand Up @@ -102,7 +103,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
234 changes: 234 additions & 0 deletions src/lib/insecure-context.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createMemoryStorageApi } from '$lib/storage/api.test-utils.js';
import { startDownload } from '$lib/storage/download.js';
import type { OperationsState } from '$lib/storage/operations.svelte.js';
import { StorageState } from '$lib/storage/state.svelte.js';
import { TabsState, type PersistedTabsState } from '$lib/storage/tabs.svelte.js';
import { runTransfer } from '$lib/storage/transfer.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}$/;

/**
* `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
* every feature that mints an id in the browser — see `randomUUID()` in
* `$lib/utils/uuid.js`, which falls back to `crypto.getRandomValues()`.
*
* These regressions live in one file rather than next to each store on
* purpose. Browser-mode Vitest evaluates SvelteKit's SSR module runner once per
* run and its first evaluation races under full-suite parallelism; splitting
* these across several spec files gives that race more than one entry point and
* leaves a Vite error overlay on the page, which then fails unrelated specs.
*/

/** `randomUUID` lives on `Crypto.prototype`; shadow it so the fallback runs. */
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 in an insecure context', () => {
beforeEach(() => {
removeRandomUUID();
});

afterEach(() => {
restoreRandomUUID();
});

// Regression: over plain HTTP on a non-localhost host `crypto.randomUUID` is
// undefined, and this module-scope initialisation threw 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 where crypto.randomUUID is unavailable', async () => {
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);

// `resetModules` does not re-evaluate Svelte-compiled modules in browser
// mode, so this reuses the singleton built above. Either way the store holds
// exactly one tab at this point, which is what makes the count below stable.
it('still mints valid v4 ids for tabs created later', 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);
}, 60_000);
});

describe('storage id minting in an insecure context', () => {
beforeEach(() => {
removeRandomUUID();
});

afterEach(() => {
restoreRandomUUID();
vi.useRealTimers();
vi.unstubAllGlobals();
});

function makeTabs(bucket = 'bucket', prefix = '') {
const storage = new StorageState({ connected: true });
storage.bucket = bucket;
storage.prefix = prefix;
storage.connectionHostname = 's3.example.com';
storage.objects = { objects: [], hasNextPage: false, currentPage: 1, pageSize: 25 };

return new TabsState(storage, {
persistEnabled: false,
connectionId: null,
navigateToLocation: vi.fn(),
replaceLocationUrl: vi.fn()
});
}

it('mints a v4 id for the initial storage tab', () => {
const ts = makeTabs();

ts.ensureInitialTab();

expect(ts.tabs).toHaveLength(1);
expect(ts.tabs[0].id).toMatch(UUID_V4);
expect(ts.activeTabId).toBe(ts.tabs[0].id);
});

it('mints a distinct v4 id per added storage tab', () => {
const ts = makeTabs();
ts.ensureInitialTab();

ts.addTab();

expect(ts.tabs).toHaveLength(2);
for (const tab of ts.tabs) {
expect(tab.id).toMatch(UUID_V4);
}
expect(new Set(ts.tabs.map((tab) => tab.id)).size).toBe(2);
expect(ts.activeTabId).toBe(ts.tabs[1].id);
});

it('mints fresh v4 ids when restoring persisted tabs', () => {
const saved: PersistedTabsState = {
tabs: [
{ id: 'old-a', label: 'A', bucket: 'bucket', prefix: '' },
{ id: 'old-b', label: 'B', bucket: 'bucket', prefix: 'x/' }
],
activeTabId: 'old-b'
};
const ts = makeTabs();

ts.restorePersistedTabs(saved);

const ids = ts.tabs.map((tab) => tab.id);

expect(ids).toHaveLength(2);
for (const id of ids) {
expect(id).toMatch(UUID_V4);
}
// The saved ids must not be reused.
expect(ids).not.toContain('old-a');
expect(ids).not.toContain('old-b');
});

it('mints a v4 job id per file when starting a download', async () => {
// `triggerDownload` builds a real anchor pointing at the manifest route and
// clicks it. Leaving that alone makes the dev server evaluate that
// `+server.ts` module, which races SvelteKit's first SSR evaluation and
// leaves a Vite error overlay on the page for unrelated specs. `document`
// itself is non-configurable in the browser, so spy on the two methods it
// uses rather than replacing the global like the node-env spec does.
const click = vi.fn();
const anchor = { click, style: {}, href: '', remove: vi.fn() };
const createElement = vi
.spyOn(document, 'createElement')
.mockReturnValue(anchor as unknown as HTMLElement);
const appendChild = vi
.spyOn(document.body, 'appendChild')
.mockImplementation(() => anchor as unknown as HTMLElement);
vi.useFakeTimers();

try {
const api = createMemoryStorageApi({
createDownloadManifest: async () => ({
id: 'manifest-1',
expiresAt: new Date().toISOString(),
files: [{ filename: 'one.txt', size: 1, part: 1 }]
})
});

const promise = startDownload(api, 'bucket', '', ['one.txt']);
await vi.advanceTimersByTimeAsync(500);
const result = await promise;

expect(result.jobIds).toHaveLength(1);
expect(result.jobIds[0]).toMatch(UUID_V4);
expect(click).toHaveBeenCalledTimes(1);
expect(anchor.href).toContain('/api/storage/download/manifests/manifest-1/1?jobId=');
} finally {
createElement.mockRestore();
appendChild.mockRestore();
}
});

it('mints v4 operation and job ids when running a transfer', async () => {
const api = createMemoryStorageApi({
async transfer(_body, onEvent) {
onEvent({
type: 'complete',
summary: { done: 1, partial: 0, failed: 0, refused: 0, cancelled: 0 }
} as Parameters<typeof onEvent>[0]);
return { complete: true };
}
});
const operations = {
startOp: vi.fn(),
updateOpJobIds: vi.fn(),
updateOpProgress: vi.fn(),
finishOp: vi.fn()
};

await runTransfer({
api,
operations: operations as unknown as OperationsState,
body: {
op: 'copy',
source: { bucket: 'x' },
dest: { bucket: 'x', prefix: 'dest/' },
items: [{ sourceKey: 'a' }]
},
label: 'Copy',
type: 'paste',
destPath: 'x/dest/'
});

const opId = operations.startOp.mock.calls[0][0];
const jobIds = operations.updateOpJobIds.mock.calls[0][1] as string[];

expect(opId).toMatch(UUID_V4);
expect(jobIds.length).toBeGreaterThan(0);
for (const jobId of jobIds) {
expect(jobId).toMatch(UUID_V4);
}
});
});
3 changes: 2 additions & 1 deletion src/lib/storage/download.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from '$lib/utils/uuid.js';
import type { StorageApi } from './api.js';

/**
Expand Down Expand Up @@ -50,7 +51,7 @@ export async function triggerManifestDownloads(
const jobIds: string[] = [];
for (const file of manifest.files) {
signal?.throwIfAborted();
const jobId = crypto.randomUUID();
const jobId = randomUUID();
jobIds.push(jobId);
onJobIds?.([...jobIds]);
await triggerDownload(manifest.id, file.part, file.filename, jobId);
Expand Down
5 changes: 3 additions & 2 deletions src/lib/storage/state.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
StorageOperation
} from '$lib/storage/types.js';
import { initPageSize, type PageSize } from '$lib/types/pagination.js';
import { randomUUID } from '$lib/utils/uuid.js';
import {
defaultPageSize,
storageCutCopyEnabled,
Expand Down Expand Up @@ -275,7 +276,7 @@ export class StorageState {
async redownloadHistory(manifestId: string, keys: string[]): Promise<void> {
const entry = this.downloadHistory.find((candidate) => candidate.id === manifestId);
const selectedEntries = entry ? entry.entries.filter((item) => keys.includes(item.key)) : [];
const operationId = crypto.randomUUID();
const operationId = randomUUID();
const controller = new AbortController();
this.operations_.startOp(
operationId,
Expand Down Expand Up @@ -562,7 +563,7 @@ export class StorageState {
for (const f of downloadItems) {
this.bookmarks.recordFileVisit(this.bucket, f.key, f.size);
}
const operationId = crypto.randomUUID();
const operationId = randomUUID();
try {
if (!connectionStore.activeConnectionId) {
addToast('error', m.storage_download_error_unknown());
Expand Down
7 changes: 4 additions & 3 deletions src/lib/storage/tabs.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { StoragePage } from '$lib/storage/types.js';
import type { PageSize } from '$lib/types/pagination.js';
import type { StorageState } from './state.svelte.js';
import { LS_TABS } from './persistence.js';
import { randomUUID } from '$lib/utils/uuid.js';
import { keyToName, parentPrefix } from './utils.js';

// ── Tab data ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -203,7 +204,7 @@ export class TabsState {
const activeIdx = saved.tabs.findIndex((pt) => pt.id === saved.activeTabId);

this.tabs = saved.tabs.map((pt) => ({
id: crypto.randomUUID(),
id: randomUUID(),
label: pt.label,
stub: true,
snapshot: {
Expand Down Expand Up @@ -284,7 +285,7 @@ export class TabsState {
}
}

const id = crypto.randomUUID();
const id = randomUUID();
const label = this.computeAutoLabel();
this.tabs = [{ id, label, stub: false, snapshot: this.captureSnapshot() }];
this.activeTabId = id;
Expand Down Expand Up @@ -376,7 +377,7 @@ export class TabsState {
/** Adds a new tab at the current location and switches to it. */
addTab(): void {
this.syncActiveTab();
const id = crypto.randomUUID();
const id = randomUUID();
const label = this.computeAutoLabel();
const newTab: Tab = { id, label, stub: false, snapshot: this.captureSnapshot() };
newTab.snapshot.selectedKeys = [];
Expand Down
5 changes: 3 additions & 2 deletions src/lib/storage/transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { tick } from 'svelte';
import * as m from '$lib/paraglide/messages.js';
import { addToast } from '$lib/stores/toast.svelte.js';
import { randomUUID } from '$lib/utils/uuid.js';
import type { StorageApi } from './api.js';
import type { OperationsState } from './operations.svelte.js';
import type { OperationType } from './types.js';
Expand Down Expand Up @@ -171,8 +172,8 @@ async function waitForJob(api: StorageApi, jobId: string): Promise<TransferItemR
*/
export async function runTransfer(options: RunTransferOptions): Promise<ItemOutcome[]> {
const { api, operations, body } = options;
const opId = crypto.randomUUID();
const jobId = crypto.randomUUID();
const opId = randomUUID();
const jobId = randomUUID();
const sourceNames = body.items.map((item) => keyToName(item.sourceKey));

operations.startOp(
Expand Down
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';

export interface ToastAction {
Expand All @@ -20,7 +22,7 @@ export function addToast(
durationMs = 5000,
actions?: ToastAction[]
): string {
const id = crypto.randomUUID();
const id = randomUUID();
toasts.push({ id, type, message, actions });
if (durationMs > 0) {
setTimeout(() => removeToast(id), durationMs);
Expand Down
Loading
Loading