From 8d16657907281406d8e69c3e0c1da4678206dcfe Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Mon, 23 Feb 2026 16:46:10 +0100 Subject: [PATCH 01/41] add monaco editor and simple trino client --- e2e/smoke.spec.ts | 5 +- e2e/trino.spec.ts | 252 +++++++++++++ messages/de.json | 22 +- messages/en.json | 22 +- package-lock.json | 48 ++- package.json | 6 +- src/lib/components/editor/MonacoEditor.svelte | 73 ++++ src/lib/components/layout/Sidebar.svelte | 4 +- src/routes/(app)/+layout.svelte | 3 +- src/routes/(app)/trino/+page.svelte | 351 ++++++++++++++++++ src/routes/api/trino/query/+server.ts | 211 +++++++++++ 11 files changed, 984 insertions(+), 13 deletions(-) create mode 100644 e2e/trino.spec.ts create mode 100644 src/lib/components/editor/MonacoEditor.svelte create mode 100644 src/routes/(app)/trino/+page.svelte create mode 100644 src/routes/api/trino/query/+server.ts diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index bcc29f04..2aa710b4 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -20,9 +20,10 @@ test.describe('Smoke tests', () => { // Dashboard content is rendered await expect(page.getByText('Welcome back')).toBeVisible(); - // Trino nav item is present but disabled + // Trino nav item is present and navigable const trinoLink = page.getByRole('link', { name: 'Trino' }); - await expect(trinoLink).toHaveAttribute('aria-disabled', 'true'); + await expect(trinoLink).toBeVisible(); + await expect(trinoLink).not.toHaveAttribute('aria-disabled', 'true'); }); test('theme toggle switches between light and dark', async ({ page }) => { diff --git a/e2e/trino.spec.ts b/e2e/trino.spec.ts new file mode 100644 index 00000000..77afb1d0 --- /dev/null +++ b/e2e/trino.spec.ts @@ -0,0 +1,252 @@ +import { test, expect, type Page } from '@playwright/test'; + +const COLUMNS = [ + { name: 'id', type: 'integer' }, + { name: 'name', type: 'varchar' } +]; + +// In SvelteKit + Vite dev mode, the `load` event fires before all dynamic module +// imports finish. Svelte 5 attaches event handlers only after those imports +// complete (hydration). Poll for the theme key — the layout writes it on mount — +// as a reliable signal that the app is fully hydrated and interactive. +async function waitForHydration(page: Page) { + await expect + .poll(() => page.evaluate(() => localStorage.getItem('theme'))) + .toMatch(/^(light|dark)$/); +} + +test.describe('Trino query editor', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(async ({ page }) => { + // Pre-populate localStorage so the connection config and SQL are ready without + // user interaction — addInitScript runs before any page scripts execute. + await page.addInitScript(() => { + localStorage.setItem('trino_url', 'http://trino.example.com:8080'); + localStorage.setItem('trino_auth_type', 'none'); + localStorage.setItem('trino_sql', 'SELECT id, name FROM users'); + }); + }); + + test('page renders with editor and results sections', async ({ page }) => { + await page.goto('/trino'); + + await expect(page.getByRole('heading', { name: 'Trino' })).toBeVisible(); + await expect(page.getByText('SQL editor')).toBeVisible(); + await expect(page.getByText('Query results')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Run query' })).toBeVisible(); + await expect(page.getByText('No results')).toBeVisible(); + }); + + test('Trino nav item is active when on /trino', async ({ page }) => { + await page.goto('/trino'); + + const trinoLink = page.getByRole('link', { name: 'Trino' }); + await expect(trinoLink).toHaveAttribute('aria-current', 'page'); + }); + + test('running a query displays the results table', async ({ page }) => { + await page.route('**/api/trino/query', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + queryId: 'q1', + columns: COLUMNS, + rows: [ + [1, 'Alice'], + [2, 'Bob'], + [3, 'Carol'] + ], + hasMore: false, + totalRows: 3 + }) + }); + }); + + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Run query' }).click(); + + const table = page.getByRole('table', { name: 'Query results' }); + await expect(table).toBeVisible(); + await expect(table.getByRole('columnheader', { name: 'id' })).toBeVisible(); + await expect(table.getByRole('columnheader', { name: 'name' })).toBeVisible(); + await expect(table.getByRole('cell', { name: '1' })).toBeVisible(); + await expect(table.getByRole('cell', { name: 'Alice' })).toBeVisible(); + await expect(page.getByText('Rows 1–3 of 3')).toBeVisible(); + }); + + test('Ctrl+Enter triggers query execution', async ({ page }) => { + let called = false; + await page.route('**/api/trino/query', async (route) => { + called = true; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + queryId: 'q1', + columns: COLUMNS, + rows: [[1, 'Alice']], + hasMore: false, + totalRows: 1 + }) + }); + }); + + await page.goto('/trino'); + await waitForHydration(page); + await page.keyboard.press('Control+Enter'); + + await expect.poll(() => called).toBe(true); + await expect(page.getByRole('table', { name: 'Query results' })).toBeVisible(); + }); + + test('query error is shown in an alert', async ({ page }) => { + await page.route('**/api/trino/query', async (route) => { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ error: 'syntax error at position 7' }) + }); + }); + + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Run query' }).click(); + + // Monaco also renders role="alert" nodes for its own accessibility — filter by content. + const alert = page.getByRole('alert').filter({ hasText: 'Query error' }); + await expect(alert).toBeVisible(); + await expect(alert.getByText('syntax error at position 7')).toBeVisible(); + }); + + test('pagination navigates between pages', async ({ page }) => { + const allRows = Array.from({ length: 30 }, (_, i) => [i + 1, `Row ${i + 1}`]); + + await page.route('**/api/trino/query', async (route) => { + const body = JSON.parse((await route.request().postData()) ?? '{}'); + const pg: number = body.page ?? 0; + const ps = 25; + const slice = allRows.slice(pg * ps, pg * ps + ps); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + queryId: 'q-pages', + columns: COLUMNS, + rows: slice, + hasMore: (pg + 1) * ps < allRows.length, + totalRows: allRows.length + }) + }); + }); + + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Run query' }).click(); + + await expect(page.getByText('Rows 1–25 of 30')).toBeVisible(); + + const nextBtn = page.getByRole('button', { name: 'Next page' }); + const prevBtn = page.getByRole('button', { name: 'Previous page' }); + await expect(prevBtn).toBeDisabled(); + await expect(nextBtn).toBeEnabled(); + + await nextBtn.click(); + await expect(page.getByText('Rows 26–30 of 30')).toBeVisible(); + await expect(prevBtn).toBeEnabled(); + await expect(nextBtn).toBeDisabled(); + + await prevBtn.click(); + await expect(page.getByText('Rows 1–25 of 30')).toBeVisible(); + }); + + test('session expired shows correct message', async ({ page }) => { + let call = 0; + await page.route('**/api/trino/query', async (route) => { + call++; + if (call === 1) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + queryId: 'exp-id', + columns: COLUMNS, + rows: [[1, 'Alice']], + hasMore: true, + totalRows: 50 + }) + }); + } else { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ error: 'session_expired' }) + }); + } + }); + + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Run query' }).click(); + await expect(page.getByRole('table')).toBeVisible(); + + await page.getByRole('button', { name: 'Next page' }).click(); + + const alert = page.getByRole('alert').filter({ hasText: 'Query session expired' }); + await expect(alert).toBeVisible(); + }); + + test('connection section expands to reveal URL and auth controls', async ({ page }) => { + await page.goto('/trino'); + + // The DaisyUI collapse uses a visually-hidden checkbox as its toggle. + await page.getByRole('checkbox', { name: 'Connection' }).check({ force: true }); + + const urlInput = page.getByRole('textbox', { name: 'URL' }); + await expect(urlInput).toBeVisible(); + await expect(urlInput).toHaveValue('http://trino.example.com:8080'); + }); + + test('switching to basic auth reveals credential fields', async ({ page }) => { + await page.goto('/trino'); + await page.getByRole('checkbox', { name: 'Connection' }).check({ force: true }); + + // No credentials visible for 'none' auth + await expect(page.getByLabel('Username')).not.toBeVisible(); + await expect(page.getByLabel('Password')).not.toBeVisible(); + + // Switch to basic auth + await page.getByRole('radio', { name: 'Basic' }).check({ force: true }); + + await expect(page.getByLabel('Username')).toBeVisible(); + await expect(page.getByLabel('Password')).toBeVisible(); + }); + + test('null cell values render as italic null placeholder', async ({ page }) => { + await page.route('**/api/trino/query', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + queryId: 'q-null', + columns: [{ name: 'value', type: 'varchar' }], + rows: [[null], ['hello']], + hasMore: false, + totalRows: 2 + }) + }); + }); + + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Run query' }).click(); + + const table = page.getByRole('table'); + await expect(table).toBeVisible(); + await expect(table.getByText('null')).toBeVisible(); + await expect(table.getByText('hello')).toBeVisible(); + }); +}); diff --git a/messages/de.json b/messages/de.json index 9b3b2158..979e2548 100644 --- a/messages/de.json +++ b/messages/de.json @@ -33,8 +33,28 @@ "page_title_dashboard": "Dashboard", "page_title_default": "Stackable", "page_title_suffix": "Stackable", + "page_title_trino": "Trino", "setup_steps_label": "Einrichtungsschritte", "language_label": "Sprache", "language_en": "English", - "language_de": "Deutsch" + "language_de": "Deutsch", + "trino_run_query": "Abfrage ausführen", + "trino_results_empty": "Keine Ergebnisse", + "trino_query_error": "Abfragefehler", + "trino_running": "Wird ausgeführt...", + "trino_connection_url_placeholder": "https://trino.example.com:8443", + "trino_editor_label": "SQL-Editor", + "trino_results_label": "Abfrageergebnisse", + "trino_rows_range": "Zeilen {start}–{end} von {total}", + "trino_session_expired": "Abfragesitzung abgelaufen – bitte Abfrage erneut ausführen.", + "trino_page_size": "Zeilen pro Seite", + "trino_prev_page": "Vorherige Seite", + "trino_next_page": "Nächste Seite", + "trino_connection_label": "Verbindung", + "trino_connection_url": "URL", + "trino_connection_auth": "Authentifizierung", + "trino_auth_none": "Keine", + "trino_auth_basic": "Basic", + "trino_auth_username": "Benutzername", + "trino_auth_password": "Passwort" } diff --git a/messages/en.json b/messages/en.json index ca545214..98683629 100644 --- a/messages/en.json +++ b/messages/en.json @@ -33,8 +33,28 @@ "page_title_dashboard": "Dashboard", "page_title_default": "Stackable", "page_title_suffix": "Stackable", + "page_title_trino": "Trino", "setup_steps_label": "Setup steps", "language_label": "Language", "language_en": "English", - "language_de": "Deutsch" + "language_de": "Deutsch", + "trino_run_query": "Run query", + "trino_results_empty": "No results", + "trino_query_error": "Query error", + "trino_running": "Running...", + "trino_connection_url_placeholder": "https://trino.example.com:8443", + "trino_editor_label": "SQL editor", + "trino_results_label": "Query results", + "trino_rows_range": "Rows {start}–{end} of {total}", + "trino_session_expired": "Query session expired — please re-run the query.", + "trino_page_size": "Rows per page", + "trino_prev_page": "Previous page", + "trino_next_page": "Next page", + "trino_connection_label": "Connection", + "trino_connection_url": "URL", + "trino_connection_auth": "Auth", + "trino_auth_none": "No auth", + "trino_auth_basic": "Basic", + "trino_auth_username": "Username", + "trino_auth_password": "Password" } diff --git a/package-lock.json b/package-lock.json index 26db3387..8b661b6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "@stackable/stackable-ui", "version": "0.0.1", "dependencies": { - "@internationalized/date": "^3.11.0" + "@internationalized/date": "^3.11.0", + "monaco-editor": "^0.55.1", + "undici": "^7.22.0" }, "devDependencies": { "@eslint/compat": "^2.0.2", @@ -19,7 +21,7 @@ "@sveltejs/kit": "^2.51.0", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.1.18", - "@types/node": "^24", + "@types/node": "^24.10.13", "daisyui": "^5.5.18", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", @@ -1490,7 +1492,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/validator": { @@ -2266,6 +2268,15 @@ "license": "MIT", "optional": true }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/effect": { "version": "3.19.17", "resolved": "https://registry.npmjs.org/effect/-/effect-3.19.17.tgz", @@ -3362,6 +3373,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mdn-data": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.23.0.tgz", @@ -3399,6 +3422,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -4568,6 +4601,15 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/package.json b/package.json index 9e4aa540..c2022bb3 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@sveltejs/kit": "^2.51.0", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.1.18", - "@types/node": "^24", + "@types/node": "^24.10.13", "daisyui": "^5.5.18", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", @@ -44,6 +44,8 @@ "zod": "^4.3.6" }, "dependencies": { - "@internationalized/date": "^3.11.0" + "@internationalized/date": "^3.11.0", + "monaco-editor": "^0.55.1", + "undici": "^7.22.0" } } diff --git a/src/lib/components/editor/MonacoEditor.svelte b/src/lib/components/editor/MonacoEditor.svelte new file mode 100644 index 00000000..4a75a366 --- /dev/null +++ b/src/lib/components/editor/MonacoEditor.svelte @@ -0,0 +1,73 @@ + + +
diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 9653898e..b0a9eaff 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -34,9 +34,7 @@ { label: m.nav_trino(), href: '/trino', - icon: 'database', - badge: m.nav_badge_soon(), - disabled: true + icon: 'database' } ] } diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 09ab9a1f..6509644a 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -10,7 +10,8 @@ let mobileOpen = $state(false); const pageTitles: Record string> = { - '/': m.page_title_dashboard + '/': m.page_title_dashboard, + '/trino': m.page_title_trino }; let title = $derived((pageTitles[page.url.pathname] ?? m.page_title_default)()); diff --git a/src/routes/(app)/trino/+page.svelte b/src/routes/(app)/trino/+page.svelte new file mode 100644 index 00000000..ae317790 --- /dev/null +++ b/src/routes/(app)/trino/+page.svelte @@ -0,0 +1,351 @@ + + + + +
+ +
+ +
+ {m.trino_connection_label()} + {connectionSummary()} +
+
+ +
+ + +
+ + +
+ {m.trino_connection_auth()} +
+ + +
+
+ + + {#if authType === 'basic'} +
+
+ + +
+
+ + +
+
+ {/if} +
+
+ + +
+
+ {m.trino_editor_label()} + +
+
+ +
+
+ + +
+
+ {m.trino_results_label()} + {#if rows.length > 0 && totalRows !== null} + + {m.trino_rows_range({ start: rowStart, end: rowEnd, total: totalRows })} + + {/if} +
+ +
+ {#if error} + + {:else if columns.length > 0} +
+ + + + {#each columns as col (col.name)} + + {/each} + + + + {#each rows as row, rowIdx (rowIdx)} + + {#each row as cell, cellIdx (cellIdx)} + + {/each} + + {/each} + +
{col.name}
+ {#if cell === null} + null + {:else} + {String(cell)} + {/if} +
+
+ {:else if !running} +

{m.trino_results_empty()}

+ {/if} +
+ + {#if columns.length > 0} +
+
+ + +
+ +
+ + +
+
+ {/if} +
+
diff --git a/src/routes/api/trino/query/+server.ts b/src/routes/api/trino/query/+server.ts new file mode 100644 index 00000000..d4f1776a --- /dev/null +++ b/src/routes/api/trino/query/+server.ts @@ -0,0 +1,211 @@ +import { json } from '@sveltejs/kit'; +import { Agent, fetch as undiciFetch } from 'undici'; +import type { RequestHandler } from './$types'; + +const POLL_TIMEOUT_MS = 30_000; +const MAX_CACHED_ROWS = 100_000; +const CACHE_TTL_MS = 5 * 60 * 1000; + +const agent = new Agent({ + connect: { rejectUnauthorized: false } +}); + +interface TrinoColumn { + name: string; + type: string; +} + +interface TrinoResponse { + id?: string; + nextUri?: string; + columns?: TrinoColumn[]; + data?: unknown[][]; + stats?: { state: string }; + error?: { message: string; errorCode: number }; +} + +interface CacheEntry { + columns: TrinoColumn[]; + rows: unknown[][]; + createdAt: number; +} + +type AuthConfig = { type: 'none' } | { type: 'basic'; username: string; password: string }; + +interface ConnectionConfig { + url: string; + auth: AuthConfig; +} + +const queryCache = new Map(); + +function evictStale() { + const cutoff = Date.now() - CACHE_TTL_MS; + for (const [id, entry] of queryCache) { + if (entry.createdAt < cutoff) queryCache.delete(id); + } +} + +function buildAuthHeaders(auth: AuthConfig): Record { + if (auth.type === 'basic') { + const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64'); + return { + 'X-Trino-User': auth.username, + Authorization: `Basic ${encoded}` + }; + } + return { 'X-Trino-User': 'anonymous' }; +} + +async function trinoFetch( + url: string, + auth: AuthConfig, + options?: RequestInit +): Promise { + const res = await undiciFetch(url, { + ...options, + dispatcher: agent, + headers: { + ...buildAuthHeaders(auth), + 'X-Trino-Source': 'stackable-ui', + ...(options?.headers ?? {}) + } + } as Parameters[1]); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Trino HTTP ${res.status}: ${text}`); + } + + return res.json() as Promise; +} + +function parseConnection(raw: unknown): ConnectionConfig | null { + if (!raw || typeof raw !== 'object') return null; + const conn = raw as Record; + const url = conn.url; + if (typeof url !== 'string' || (!url.startsWith('http://') && !url.startsWith('https://'))) { + return null; + } + const auth = conn.auth as Record | undefined; + if (!auth || typeof auth !== 'object') return null; + if (auth.type === 'none') { + return { url, auth: { type: 'none' } }; + } + if ( + auth.type === 'basic' && + typeof auth.username === 'string' && + typeof auth.password === 'string' + ) { + return { url, auth: { type: 'basic', username: auth.username, password: auth.password } }; + } + return null; +} + +const ALLOWED_PAGE_SIZES = new Set([25, 50, 100]); + +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json(); + const pageSize: number = ALLOWED_PAGE_SIZES.has(body.pageSize) ? body.pageSize : 25; + const page: number = typeof body.page === 'number' && body.page >= 0 ? Math.floor(body.page) : 0; + + // Pagination: slice from server-side cache, no Trino re-execution. + if (typeof body.queryId === 'string' && body.queryId) { + const cached = queryCache.get(body.queryId); + if (!cached) { + console.info('[trino] cache miss for queryId=%s (session expired)', body.queryId); + return json({ error: 'session_expired' }, { status: 404 }); + } + const start = page * pageSize; + console.debug('[trino] cache hit queryId=%s page=%d pageSize=%d', body.queryId, page, pageSize); + return json({ + queryId: body.queryId, + columns: cached.columns, + rows: cached.rows.slice(start, start + pageSize), + hasMore: start + pageSize < cached.rows.length, + totalRows: cached.rows.length + }); + } + + // New query execution — connection config required. + const { sql } = body; + if (!sql?.trim()) { + return json({ error: 'No SQL provided' }, { status: 400 }); + } + + const connection = parseConnection(body.connection); + if (!connection) { + return json( + { + error: + 'Invalid or missing connection config. Provide connection.url (http/https) and connection.auth.' + }, + { status: 400 } + ); + } + + evictStale(); + + const start = Date.now(); + console.info('[trino] executing query on %s', connection.url); + + const deadline = start + POLL_TIMEOUT_MS; + let columns: TrinoColumn[] = []; + let rows: unknown[][] = []; + + try { + let response = await trinoFetch(`${connection.url}/v1/statement`, connection.auth, { + method: 'POST', + body: sql.replace(/;\s*$/, '').trim(), + headers: { 'Content-Type': 'text/plain' } + }); + + if (response.error) { + console.info('[trino] query error: %s', response.error.message); + return json({ error: response.error.message }, { status: 400 }); + } + + if (response.columns) columns = response.columns; + if (response.data) rows = rows.concat(response.data); + + while (response.nextUri && rows.length < MAX_CACHED_ROWS) { + if (Date.now() > deadline) { + console.info('[trino] query timed out after %dms on %s', POLL_TIMEOUT_MS, connection.url); + return json({ error: 'Query timed out after 30 seconds' }, { status: 408 }); + } + + response = await trinoFetch(response.nextUri, connection.auth); + + if (response.error) { + console.info('[trino] query error: %s', response.error.message); + return json({ error: response.error.message }, { status: 400 }); + } + + if (response.columns && columns.length === 0) columns = response.columns; + if (response.data) rows = rows.concat(response.data); + } + + const queryId = crypto.randomUUID(); + queryCache.set(queryId, { columns, rows, createdAt: Date.now() }); + + console.info( + '[trino] query complete: %d rows, %d cols, %dms, queryId=%s', + rows.length, + columns.length, + Date.now() - start, + queryId + ); + + return json({ + queryId, + columns, + rows: rows.slice(0, pageSize), + hasMore: rows.length > pageSize, + totalRows: rows.length + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error('[trino] unexpected error on %s: %s', connection.url, message); + return json({ error: message }, { status: 500 }); + } +}; From 132bcb6220898c9f42769f3f69d4933eddea483b Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Mon, 23 Feb 2026 17:07:58 +0100 Subject: [PATCH 02/41] fix: run query shortcut not working when editor is selected --- src/lib/components/editor/MonacoEditor.svelte | 13 ++++++++++++- src/routes/(app)/trino/+page.svelte | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/lib/components/editor/MonacoEditor.svelte b/src/lib/components/editor/MonacoEditor.svelte index 4a75a366..162e316a 100644 --- a/src/lib/components/editor/MonacoEditor.svelte +++ b/src/lib/components/editor/MonacoEditor.svelte @@ -4,10 +4,12 @@ let { value = $bindable(), - language = 'sql' + language = 'sql', + onExecute }: { value?: string; language?: string; + onExecute?: () => void; } = $props(); let container: HTMLDivElement; @@ -54,6 +56,15 @@ value = editor!.getValue(); }); + if (onExecute) { + editor.addAction({ + id: 'execute-query', + label: 'Execute Query', + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter], + run: onExecute + }); + } + // Watch for theme changes observer = new MutationObserver(() => { monaco?.editor.setTheme(getMonacoTheme()); diff --git a/src/routes/(app)/trino/+page.svelte b/src/routes/(app)/trino/+page.svelte index ae317790..756ce90a 100644 --- a/src/routes/(app)/trino/+page.svelte +++ b/src/routes/(app)/trino/+page.svelte @@ -242,7 +242,7 @@
- +
From da4c2c116c9d0ac91c5ff7fb04f147bd2ae377d5 Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Wed, 25 Feb 2026 14:00:27 +0100 Subject: [PATCH 03/41] wip: add oidc authentication with better-auth --- .env.example | 18 +- .github/workflows/pr_checks.yaml | 14 + .gitignore | 1 + .../helm/stackable-ui/templates/_helpers.tpl | 11 + .../stackable-ui/templates/configmap.yaml | 5 +- .../stackable-ui/templates/deployment.yaml | 36 + deploy/helm/stackable-ui/templates/pvc.yaml | 17 + .../helm/stackable-ui/templates/secret.yaml | 2 + deploy/helm/stackable-ui/values.yaml | 32 + e2e/auth.setup.ts | 26 + e2e/auth.spec.ts | 56 ++ messages/de.json | 7 +- messages/en.json | 7 +- package-lock.json | 764 ++++++++++++++++-- package.json | 5 +- playwright.config.ts | 26 +- src/app.d.ts | 7 +- src/hooks.server.ts | 28 +- src/lib/auth-client.ts | 6 + src/lib/components/layout/Header.svelte | 78 +- src/lib/server/auth.ts | 35 + src/routes/(app)/+layout.server.ts | 5 + src/routes/(app)/+layout.svelte | 9 +- src/routes/auth/login/+page.server.ts | 12 + src/routes/auth/login/+page.svelte | 86 ++ src/routes/auth/logout/+page.server.ts | 8 + 26 files changed, 1216 insertions(+), 85 deletions(-) create mode 100644 deploy/helm/stackable-ui/templates/pvc.yaml create mode 100644 e2e/auth.setup.ts create mode 100644 e2e/auth.spec.ts create mode 100644 src/lib/auth-client.ts create mode 100644 src/lib/server/auth.ts create mode 100644 src/routes/auth/login/+page.server.ts create mode 100644 src/routes/auth/login/+page.svelte create mode 100644 src/routes/auth/logout/+page.server.ts diff --git a/.env.example b/.env.example index a5e7686d..22280e9e 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,17 @@ -# Add project environment variables here when needed. +# Better Auth configuration +# A long random secret used to sign sessions and tokens (min 32 characters) +BETTER_AUTH_SECRET=change-me-to-a-long-random-secret-min-32-chars + +# The publicly accessible base URL of this application +BETTER_AUTH_URL=http://localhost:5173 + +# OIDC provider configuration (Keycloak, Entra ID, or any compliant OIDC provider) +# The issuer URL — Better Auth appends /.well-known/openid-configuration automatically +AUTH_ISSUER=https://your-idp.example.com/realms/your-realm + +# The client ID and secret registered in your OIDC provider +AUTH_CLIENT_ID=stackable-ui +AUTH_CLIENT_SECRET=your-client-secret + +# SQLite database path (must be on a persistent volume in Kubernetes) +DATABASE_PATH=/data/auth.db diff --git a/.github/workflows/pr_checks.yaml b/.github/workflows/pr_checks.yaml index 2f7d897b..16cdeb49 100644 --- a/.github/workflows/pr_checks.yaml +++ b/.github/workflows/pr_checks.yaml @@ -52,6 +52,12 @@ jobs: runs-on: ubuntu-latest env: PLAYWRIGHT_BASE_URL: http://localhost:4173 + BETTER_AUTH_URL: http://localhost:4173 + BETTER_AUTH_SECRET: ci-secret-for-testing-only-not-production + AUTH_ISSUER: http://localhost:8080/default + AUTH_CLIENT_ID: stackable-ui + AUTH_CLIENT_SECRET: ci-client-secret + DATABASE_PATH: /tmp/auth-test.db steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -70,6 +76,14 @@ jobs: - name: Install Playwright browsers run: npx playwright install --with-deps chromium firefox + - name: Start mock OAuth2 server + run: | + docker run -d \ + --name mock-oauth2 \ + -p 8080:8080 \ + ghcr.io/navikt/mock-oauth2-server:2.1.10 + timeout 30 bash -c 'until curl -sf http://localhost:8080/default/.well-known/openid-configuration > /dev/null; do sleep 1; done' + - name: Build application run: npm run build diff --git a/.gitignore b/.gitignore index 0b40116e..04b6c398 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ dist/ # Playwright e2e/test-results +e2e/.auth # Paraglide src/lib/paraglide project.inlang/cache/ diff --git a/deploy/helm/stackable-ui/templates/_helpers.tpl b/deploy/helm/stackable-ui/templates/_helpers.tpl index 80e8388b..517552a9 100644 --- a/deploy/helm/stackable-ui/templates/_helpers.tpl +++ b/deploy/helm/stackable-ui/templates/_helpers.tpl @@ -74,6 +74,17 @@ Generate session secret {{- end }} {{- end }} +{{/* +Generate Better Auth secret +*/}} +{{- define "stackable-ui.betterAuthSecret" -}} +{{- if .Values.auth.secret }} +{{- .Values.auth.secret }} +{{- else }} +{{- randAlphaNum 64 }} +{{- end }} +{{- end }} + {{/* Image name */}} diff --git a/deploy/helm/stackable-ui/templates/configmap.yaml b/deploy/helm/stackable-ui/templates/configmap.yaml index c90a91bd..26c8a47d 100644 --- a/deploy/helm/stackable-ui/templates/configmap.yaml +++ b/deploy/helm/stackable-ui/templates/configmap.yaml @@ -4,4 +4,7 @@ metadata: name: {{ include "stackable-ui.fullname" . }} labels: {{- include "stackable-ui.labels" . | nindent 4 }} -data: {} +data: + auth-issuer: {{ .Values.auth.issuer | quote }} + auth-client-id: {{ .Values.auth.clientId | quote }} + better-auth-url: {{ .Values.auth.url | quote }} diff --git a/deploy/helm/stackable-ui/templates/deployment.yaml b/deploy/helm/stackable-ui/templates/deployment.yaml index 4e784f0c..e9ccebe7 100644 --- a/deploy/helm/stackable-ui/templates/deployment.yaml +++ b/deploy/helm/stackable-ui/templates/deployment.yaml @@ -63,12 +63,48 @@ spec: secretKeyRef: name: {{ include "stackable-ui.fullname" . }} key: session-secret + - name: BETTER_AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ include "stackable-ui.fullname" . }} + key: better-auth-secret + - name: AUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "stackable-ui.fullname" . }} + key: oidc-client-secret + - name: BETTER_AUTH_URL + valueFrom: + configMapKeyRef: + name: {{ include "stackable-ui.fullname" . }} + key: better-auth-url + - name: AUTH_ISSUER + valueFrom: + configMapKeyRef: + name: {{ include "stackable-ui.fullname" . }} + key: auth-issuer + - name: AUTH_CLIENT_ID + valueFrom: + configMapKeyRef: + name: {{ include "stackable-ui.fullname" . }} + key: auth-client-id + - name: DATABASE_PATH + value: {{ .Values.config.databasePath | quote }} volumeMounts: - name: tmp mountPath: /tmp + - name: data + mountPath: /data volumes: - name: tmp emptyDir: {} + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ if .Values.persistence.existingClaim }}{{ .Values.persistence.existingClaim }}{{ else }}{{ include "stackable-ui.fullname" . }}-data{{ end }} + {{- else }} + emptyDir: {} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/deploy/helm/stackable-ui/templates/pvc.yaml b/deploy/helm/stackable-ui/templates/pvc.yaml new file mode 100644 index 00000000..a1f9235b --- /dev/null +++ b/deploy/helm/stackable-ui/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "stackable-ui.fullname" . }}-data + labels: + {{- include "stackable-ui.labels" . | nindent 4 }} +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- with .Values.persistence.storageClassName }} + storageClassName: {{ . }} + {{- end }} +{{- end }} diff --git a/deploy/helm/stackable-ui/templates/secret.yaml b/deploy/helm/stackable-ui/templates/secret.yaml index 646678c6..daaf4ba7 100644 --- a/deploy/helm/stackable-ui/templates/secret.yaml +++ b/deploy/helm/stackable-ui/templates/secret.yaml @@ -7,3 +7,5 @@ metadata: type: Opaque stringData: session-secret: {{ include "stackable-ui.sessionSecret" . | quote }} + better-auth-secret: {{ include "stackable-ui.betterAuthSecret" . | quote }} + oidc-client-secret: {{ .Values.auth.clientSecret | quote }} diff --git a/deploy/helm/stackable-ui/values.yaml b/deploy/helm/stackable-ui/values.yaml index e4a68488..4ba2a577 100644 --- a/deploy/helm/stackable-ui/values.yaml +++ b/deploy/helm/stackable-ui/values.yaml @@ -1,6 +1,8 @@ --- # Default values for stackable-ui +# NOTE: SQLite requires a single writer. Keep replicaCount at 1 when +# persistence.enabled is true to avoid concurrent-write corruption. replicaCount: 1 image: @@ -113,6 +115,36 @@ affinity: {} config: # Node.js environment nodeEnv: production + # SQLite database path inside the container (should be on the persistence volume) + databasePath: "/data/auth.db" # Session secret for signing cookies (auto-generated if not provided) sessionSecret: "" + +# Better Auth configuration +auth: + # The publicly accessible base URL of this application (used for OIDC callback URLs) + # Required. Example: https://stackable-ui.example.com + url: "" + # Better Auth secret — long random string (min 32 chars). Auto-generated if not provided. + secret: "" + # OIDC provider issuer URL (without /.well-known/openid-configuration suffix) + # Required. Example: https://your-idp.example.com/realms/your-realm + issuer: "" + # Client ID registered in the OIDC provider + # Required. + clientId: "" + # Client secret registered in the OIDC provider + # Required. + clientSecret: "" + +# SQLite persistence for the Better Auth database. +# NOTE: Requires replicaCount: 1 — SQLite does not support concurrent writes from multiple pods. +persistence: + enabled: true + size: 1Gi + # Storage class name. Leave empty to use the cluster default. + storageClassName: "" + accessMode: ReadWriteOnce + # Set to reuse an existing PVC instead of creating a new one. + existingClaim: "" diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 00000000..7da33b08 --- /dev/null +++ b/e2e/auth.setup.ts @@ -0,0 +1,26 @@ +import { test as setup, expect } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const authFile = path.join(__dirname, '.auth/user.json'); + +setup('authenticate via mock OIDC', async ({ page }) => { + // Navigate to the app — auth guard redirects to /auth/login + await page.goto('/'); + await expect(page).toHaveURL(/\/auth\/login/); + + // Click "Sign in with SSO" + await page.getByRole('button', { name: /sign in with sso/i }).click(); + + // mock-oauth2-server presents a simple username form (input[name="username"], no label text) + await page.locator('input[name="username"]').fill('testuser'); + await page.locator('input[type="submit"]').click(); + + // Should land back on the app dashboard + await expect(page).toHaveURL('/'); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + + // Save authenticated storage state + await page.context().storageState({ path: authFile }); +}); diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 00000000..48981f03 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Authentication', () => { + test.use({ locale: 'en-US' }); + + test('unauthenticated access redirects to login page', async ({ browser }) => { + // Use a fresh context with no saved session + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto('/'); + await expect(page).toHaveURL(/\/auth\/login/); + await expect(page.getByRole('heading', { name: 'Sign in to Stackable' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign in with SSO' })).toBeVisible(); + + await context.close(); + }); + + test('login page preserves redirectTo query parameter', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto('/some-page?foo=bar'); + await expect(page).toHaveURL(/\/auth\/login\?redirectTo=/); + + await context.close(); + }); + + test('authenticated user sees dashboard', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveURL('/'); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + }); + + test('user menu shows sign out option', async ({ page }) => { + await page.goto('/'); + + const userMenuButton = page.getByRole('button', { name: 'User menu' }); + await expect(userMenuButton).toBeVisible(); + + await userMenuButton.click(); + + const signOutLink = page.getByRole('link', { name: 'Sign out' }); + await expect(signOutLink).toBeVisible(); + await expect(signOutLink).toHaveAttribute('href', '/auth/logout'); + }); + + test('sign out redirects to login page', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'User menu' }).click(); + await page.getByRole('link', { name: 'Sign out' }).click(); + + await expect(page).toHaveURL(/\/auth\/login/); + await expect(page.getByRole('button', { name: 'Sign in with SSO' })).toBeVisible(); + }); +}); diff --git a/messages/de.json b/messages/de.json index 9b3b2158..0225119a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -36,5 +36,10 @@ "setup_steps_label": "Einrichtungsschritte", "language_label": "Sprache", "language_en": "English", - "language_de": "Deutsch" + "language_de": "Deutsch", + "auth_login_title": "Bei Stackable anmelden", + "auth_login_subtitle": "Verwenden Sie den SSO-Anbieter Ihrer Organisation, um auf die Plattform zuzugreifen.", + "auth_login_button": "Mit SSO anmelden", + "auth_login_error": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "header_sign_out": "Abmelden" } diff --git a/messages/en.json b/messages/en.json index ca545214..205def61 100644 --- a/messages/en.json +++ b/messages/en.json @@ -36,5 +36,10 @@ "setup_steps_label": "Setup steps", "language_label": "Language", "language_en": "English", - "language_de": "Deutsch" + "language_de": "Deutsch", + "auth_login_title": "Sign in to Stackable", + "auth_login_subtitle": "Use your organisation's SSO provider to access the platform.", + "auth_login_button": "Sign in with SSO", + "auth_login_error": "Sign-in failed. Please try again.", + "header_sign_out": "Sign out" } diff --git a/package-lock.json b/package-lock.json index f9ba3250..df2f59d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "@stackable/stackable-ui", "version": "0.0.1", "dependencies": { - "@internationalized/date": "^3.11.0" + "@internationalized/date": "^3.11.0", + "better-auth": "^1.4.19", + "better-sqlite3": "^12.6.2" }, "devDependencies": { "@eslint/compat": "^2.0.2", @@ -19,6 +21,7 @@ "@sveltejs/kit": "^2.51.0", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.1.18", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^24", "daisyui": "^5.5.18", "eslint": "^9.39.2", @@ -70,6 +73,17 @@ "node": ">=6.9.0" } }, + "node_modules/@better-auth/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==", + "license": "MIT" + }, + "node_modules/@better-fetch/fetch": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -426,7 +440,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -437,7 +451,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -448,7 +462,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -458,14 +472,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -512,6 +526,30 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -545,7 +583,7 @@ "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@poppinss/macroable": { @@ -1050,14 +1088,13 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1083,7 +1120,7 @@ "version": "2.52.0", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.52.0.tgz", "integrity": "sha512-zG+HmJuSF7eC0e7xt2htlOcEMAdEtlVdb7+gAr+ef08EhtwUsjLxcAwBgUCJY3/5p08OVOxVZti91WfXeuLvsg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1126,14 +1163,14 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@sveltejs/vite-plugin-svelte": { "version": "6.2.4", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", @@ -1154,7 +1191,7 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "obug": "^2.1.0" @@ -1449,18 +1486,28 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1491,7 +1538,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/validator": { @@ -1852,7 +1899,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1915,7 +1962,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1956,7 +2003,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1969,6 +2016,218 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-auth": { + "version": "1.4.19", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.4.19.tgz", + "integrity": "sha512-3RlZJcA0+NH25wYD85vpIGwW9oSTuEmLIaGbT8zg41w/Pa2hVWHKedjoUHHJtnzkBXzDb+CShkLnSw7IThDdqQ==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.4.19", + "@better-auth/telemetry": "1.4.19", + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21", + "@noble/ciphers": "^2.0.0", + "@noble/hashes": "^2.0.0", + "better-call": "1.1.8", + "defu": "^6.1.4", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1", + "zod": "^4.3.5" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": ">=0.41.0", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/core": { + "version": "1.4.19", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.4.19.tgz", + "integrity": "sha512-uADLHG1jc5BnEJi7f6ijUN5DmPPRSj++7m/G19z3UqA3MVCo4Y4t1MMa4IIxLCqGDFv22drdfxescgW+HnIowA==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "zod": "^4.3.5" + }, + "peerDependencies": { + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21", + "better-call": "1.1.8", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1" + } + }, + "node_modules/better-auth/node_modules/@better-auth/telemetry": { + "version": "1.4.19", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.4.19.tgz", + "integrity": "sha512-ApGNS7olCTtDpKF8Ow3Z+jvFAirOj7c4RyFUpu8axklh3mH57ndpfUAUjhgA8UVoaaH/mnm/Tl884BlqiewLyw==", + "dependencies": { + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21" + }, + "peerDependencies": { + "@better-auth/core": "1.4.19" + } + }, + "node_modules/better-auth/node_modules/kysely": { + "version": "0.28.11", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.11.tgz", + "integrity": "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/better-call": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.1.8.tgz", + "integrity": "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.3.0", + "@better-fetch/fetch": "^1.1.4", + "rou3": "^0.7.10", + "set-cookie-parser": "^2.7.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1980,6 +2239,30 @@ "concat-map": "0.0.1" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2037,6 +2320,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", @@ -2054,7 +2343,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -2133,7 +2422,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -2210,6 +2499,21 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", @@ -2225,6 +2529,15 @@ } } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2236,17 +2549,22 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -2256,7 +2574,7 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/dlv": { @@ -2279,6 +2597,15 @@ "fast-check": "^3.23.1" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", @@ -2512,7 +2839,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/espree": { @@ -2564,7 +2891,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -2610,6 +2937,15 @@ "node": ">=0.10.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", @@ -2659,7 +2995,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2686,6 +3022,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2724,6 +3066,12 @@ "dev": true, "license": "ISC" }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -2749,6 +3097,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2815,6 +3169,26 @@ "human-id": "dist/cli.js" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2852,6 +3226,18 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -2940,6 +3326,15 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-sha256": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz", @@ -3023,7 +3418,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -3343,7 +3738,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/locate-path": { @@ -3373,7 +3768,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -3393,6 +3788,18 @@ "dev": true, "license": "ISC" }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3410,17 +3817,22 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -3430,7 +3842,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -3447,7 +3859,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -3462,6 +3874,27 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.1.0.tgz", + "integrity": "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3469,6 +3902,18 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/normalize-url": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", @@ -3487,13 +3932,22 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, + "devOptional": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3588,14 +4042,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -3640,7 +4094,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -3773,6 +4227,33 @@ "node": ">=4" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3897,6 +4378,16 @@ "license": "MIT", "optional": true }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3925,6 +4416,44 @@ "license": "MIT", "optional": true }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3984,7 +4513,7 @@ "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -4025,11 +4554,17 @@ "fsevents": "~2.3.2" } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mri": "^1.1.0" @@ -4038,6 +4573,26 @@ "node": ">=6" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", @@ -4052,7 +4607,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4061,6 +4615,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4084,11 +4644,56 @@ "node": ">=8" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -4103,7 +4708,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -4121,6 +4726,15 @@ "kysely": "*" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -4185,7 +4799,7 @@ "version": "5.51.2", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.51.2.tgz", "integrity": "sha512-AqApqNOxVS97V4Ko9UHTHeSuDJrwauJhZpLDs1gYD8Jk48ntCSWD7NxKje+fnGn5Ja1O3u2FzQZHPdifQjXe3w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -4267,7 +4881,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -4421,6 +5035,34 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tiny-case": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", @@ -4433,7 +5075,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -4458,7 +5100,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -4532,6 +5174,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4649,7 +5303,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/uuid": { @@ -4696,7 +5349,7 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "^0.27.0", @@ -5213,7 +5866,7 @@ "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -5270,7 +5923,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", - "dev": true, + "devOptional": true, "license": "MIT", "workspaces": [ "tests/deps/*", @@ -5319,6 +5972,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -5350,14 +6009,13 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index f0cf943d..8a5ae9c3 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@sveltejs/kit": "^2.51.0", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.1.18", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^24", "daisyui": "^5.5.18", "eslint": "^9.39.2", @@ -45,6 +46,8 @@ "zod": "^4.3.6" }, "dependencies": { - "@internationalized/date": "^3.11.0" + "@internationalized/date": "^3.11.0", + "better-auth": "^1.4.19", + "better-sqlite3": "^12.6.2" } } diff --git a/playwright.config.ts b/playwright.config.ts index dd4d1175..cde53514 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,18 +19,30 @@ export default defineConfig({ }, projects: [ { - name: 'firefox', + name: 'setup', + testMatch: /auth\.setup\.ts/, use: { - browserName: 'firefox', + browserName: 'chromium', viewport: { width: 1280, height: 720 } } }, + { + name: 'firefox', + use: { + browserName: 'firefox', + viewport: { width: 1280, height: 720 }, + storageState: 'e2e/.auth/user.json' + }, + dependencies: ['setup'] + }, { name: 'chromium', use: { browserName: 'chromium', - viewport: { width: 1280, height: 720 } - } + viewport: { width: 1280, height: 720 }, + storageState: 'e2e/.auth/user.json' + }, + dependencies: ['setup'] }, { name: 'mobile', @@ -38,8 +50,10 @@ export default defineConfig({ browserName: 'chromium', viewport: { width: 393, height: 851 }, isMobile: true, - hasTouch: true - } + hasTouch: true, + storageState: 'e2e/.auth/user.json' + }, + dependencies: ['setup'] } ] }); diff --git a/src/app.d.ts b/src/app.d.ts index 77dbca69..4b7fbdd7 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,10 +1,15 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces +import type { auth } from '$lib/server/auth'; + declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + user: typeof auth.$Infer.Session.user | null; + session: typeof auth.$Infer.Session.session | null; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index a8be42a8..54ad98f3 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,6 +1,9 @@ import { paraglideMiddleware } from '$lib/paraglide/server'; -import { type Handle } from '@sveltejs/kit'; +import { building } from '$app/environment'; +import { redirect, type Handle } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; +import { svelteKitHandler } from 'better-auth/svelte-kit'; +import { auth } from '$lib/server/auth'; const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { @@ -11,6 +14,23 @@ const handleParaglide: Handle = ({ event, resolve }) => }); }); -// Each function acts as a middleware, receiving the request handle -// And returning a handle which gets passed to the next function -export const handle = sequence(handleParaglide); +const handleAuth: Handle = ({ event, resolve }) => + svelteKitHandler({ event, resolve, auth, building }); + +const PUBLIC_PATHS = ['/auth/login', '/auth/logout', '/api/auth']; + +const handleAuthGuard: Handle = async ({ event, resolve }) => { + const session = await auth.api.getSession({ headers: event.request.headers }); + event.locals.user = session?.user ?? null; + event.locals.session = session?.session ?? null; + + const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p)); + if (!isPublic && !event.locals.user) { + const redirectTo = encodeURIComponent(event.url.pathname + event.url.search); + throw redirect(302, `/auth/login?redirectTo=${redirectTo}`); + } + + return resolve(event); +}; + +export const handle = sequence(handleParaglide, handleAuth, handleAuthGuard); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 00000000..a6f0fb40 --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,6 @@ +import { createAuthClient } from 'better-auth/svelte'; +import { genericOAuthClient } from 'better-auth/client/plugins'; + +export const authClient = createAuthClient({ + plugins: [genericOAuthClient()] +}); diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte index 63a440d8..06e09187 100644 --- a/src/lib/components/layout/Header.svelte +++ b/src/lib/components/layout/Header.svelte @@ -2,16 +2,32 @@ import * as m from '$lib/paraglide/messages.js'; import LanguageSwitcher from './LanguageSwitcher.svelte'; import ThemeToggle from './ThemeToggle.svelte'; + import type { auth } from '$lib/server/auth'; + + type User = typeof auth.$Infer.Session.user | null; let { title = m.page_title_dashboard(), mobileOpen = false, + user = null, onToggleMobile }: { title?: string; mobileOpen?: boolean; + user?: User; onToggleMobile?: () => void; } = $props(); + + const initials = $derived( + user?.name + ? user.name + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2) + : '?' + );
-
+ + diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 00000000..9fce993c --- /dev/null +++ b/src/lib/server/auth.ts @@ -0,0 +1,35 @@ +import { betterAuth } from 'better-auth'; +import { genericOAuth } from 'better-auth/plugins'; +import Database from 'better-sqlite3'; +import { env } from '$env/dynamic/private'; + +export const auth = betterAuth({ + secret: env.BETTER_AUTH_SECRET, + baseURL: env.BETTER_AUTH_URL, + database: new Database(env.DATABASE_PATH ?? '/data/auth.db'), + session: { + cookieCache: { enabled: true, maxAge: 5 * 60 } + }, + plugins: [ + genericOAuth({ + config: [ + { + providerId: 'oidc', + discoveryUrl: `${env.AUTH_ISSUER}/.well-known/openid-configuration`, + clientId: env.AUTH_CLIENT_ID, + clientSecret: env.AUTH_CLIENT_SECRET, + scopes: ['openid', 'profile', 'email'], + pkce: true, + mapProfileToUser: async (profile) => { + const fullName = [profile.given_name, profile.family_name].filter(Boolean).join(' '); + return { + name: profile.name || fullName || profile.preferred_username || profile.email, + email: profile.email || profile.preferred_username, + image: profile.picture || null + }; + } + } + ] + }) + ] +}); diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts index e69de29b..c77935c7 100644 --- a/src/routes/(app)/+layout.server.ts +++ b/src/routes/(app)/+layout.server.ts @@ -0,0 +1,5 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals }) => { + return { user: locals.user }; +}; diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 09ab9a1f..71ea3515 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -4,7 +4,7 @@ import Sidebar from '$lib/components/layout/Sidebar.svelte'; import Header from '$lib/components/layout/Header.svelte'; - let { children } = $props(); + let { children, data } = $props(); let sidebarCollapsed = $state(false); let mobileOpen = $state(false); @@ -24,7 +24,12 @@
-
(mobileOpen = !mobileOpen)} /> +
(mobileOpen = !mobileOpen)} + />
{@render children()} diff --git a/src/routes/auth/login/+page.server.ts b/src/routes/auth/login/+page.server.ts new file mode 100644 index 00000000..3e438def --- /dev/null +++ b/src/routes/auth/login/+page.server.ts @@ -0,0 +1,12 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, url }) => { + if (locals.user) { + throw redirect(302, '/'); + } + + const redirectTo = url.searchParams.get('redirectTo') ?? '/'; + + return { redirectTo }; +}; diff --git a/src/routes/auth/login/+page.svelte b/src/routes/auth/login/+page.svelte new file mode 100644 index 00000000..7398269a --- /dev/null +++ b/src/routes/auth/login/+page.svelte @@ -0,0 +1,86 @@ + + + + {m.auth_login_title()} | {m.page_title_suffix()} + + +
+
+
+
+ +
+ +

{m.auth_login_title()}

+

{m.auth_login_subtitle()}

+ + {#if error} + + {/if} + +
+ +
+
+
+
diff --git a/src/routes/auth/logout/+page.server.ts b/src/routes/auth/logout/+page.server.ts new file mode 100644 index 00000000..f40704cd --- /dev/null +++ b/src/routes/auth/logout/+page.server.ts @@ -0,0 +1,8 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { auth } from '$lib/server/auth'; + +export const load: PageServerLoad = async ({ request }) => { + await auth.api.signOut({ headers: request.headers }); + throw redirect(302, '/auth/login'); +}; From 0d0bcaf4847351b78a9d3e3b6369f3c341c5db6e Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Wed, 25 Feb 2026 14:20:30 +0100 Subject: [PATCH 04/41] fix invisible Enter symbol on button --- src/routes/(app)/trino/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/(app)/trino/+page.svelte b/src/routes/(app)/trino/+page.svelte index 756ce90a..a1c34dbc 100644 --- a/src/routes/(app)/trino/+page.svelte +++ b/src/routes/(app)/trino/+page.svelte @@ -237,7 +237,7 @@ {m.trino_running()} {:else} {m.trino_run_query()} - Ctrl+↵ + Ctrl+↵ {/if}
From b018079f8cd2624404e7277ab65ab1b000245d3e Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Wed, 25 Feb 2026 14:34:41 +0100 Subject: [PATCH 05/41] replace per-component theme detection with shared reactive state --- src/lib/components/editor/MonacoEditor.svelte | 26 +++++++------------ src/lib/theme.svelte.ts | 3 +++ src/routes/+layout.svelte | 10 +++++++ 3 files changed, 22 insertions(+), 17 deletions(-) create mode 100644 src/lib/theme.svelte.ts diff --git a/src/lib/components/editor/MonacoEditor.svelte b/src/lib/components/editor/MonacoEditor.svelte index 162e316a..3932af87 100644 --- a/src/lib/components/editor/MonacoEditor.svelte +++ b/src/lib/components/editor/MonacoEditor.svelte @@ -1,6 +1,7 @@ diff --git a/src/lib/theme.svelte.ts b/src/lib/theme.svelte.ts new file mode 100644 index 00000000..fba15341 --- /dev/null +++ b/src/lib/theme.svelte.ts @@ -0,0 +1,3 @@ +import { browser } from '$app/environment'; + +export const theme = $state({ current: browser ? (document.documentElement.dataset.theme ?? 'dark') : 'dark' }); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5d83456e..5a33388c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,7 +1,17 @@ {@render children()} From 398394274a6f11a09b696d05a0c9584618883ac1 Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Wed, 25 Feb 2026 14:44:37 +0100 Subject: [PATCH 06/41] Add missing i18n message --- messages/de.json | 3 ++- messages/en.json | 3 ++- src/routes/(app)/trino/+page.svelte | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/messages/de.json b/messages/de.json index 979e2548..526912cf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -56,5 +56,6 @@ "trino_auth_none": "Keine", "trino_auth_basic": "Basic", "trino_auth_username": "Benutzername", - "trino_auth_password": "Passwort" + "trino_auth_password": "Passwort", + "trino_unknown_error": "Unbekannter Fehler" } diff --git a/messages/en.json b/messages/en.json index 98683629..5c22111b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -56,5 +56,6 @@ "trino_auth_none": "No auth", "trino_auth_basic": "Basic", "trino_auth_username": "Username", - "trino_auth_password": "Password" + "trino_auth_password": "Password", + "trino_unknown_error": "Unknown error" } diff --git a/src/routes/(app)/trino/+page.svelte b/src/routes/(app)/trino/+page.svelte index a1c34dbc..fc3aa90f 100644 --- a/src/routes/(app)/trino/+page.svelte +++ b/src/routes/(app)/trino/+page.svelte @@ -103,7 +103,7 @@ totalRows = data.totalRows ?? null; } } catch (err) { - error = err instanceof Error ? err.message : 'Unknown error'; + error = err instanceof Error ? err.message : m.trino_unknown_error(); } finally { running = false; } From 3aa197a4c2e48047c81bcb73fecba262bc9919ea Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Wed, 25 Feb 2026 15:09:42 +0100 Subject: [PATCH 07/41] Remove undici & skip tls validation in dev through env --- .env.development | 4 ++++ package-lock.json | 12 +----------- package.json | 3 +-- src/routes/api/trino/query/+server.ts | 10 ++-------- 4 files changed, 8 insertions(+), 21 deletions(-) create mode 100644 .env.development diff --git a/.env.development b/.env.development new file mode 100644 index 00000000..ca62220a --- /dev/null +++ b/.env.development @@ -0,0 +1,4 @@ +# Disable TLS certificate verification for development (e.g. self-signed Trino certs). +# This file is committed and loaded automatically in dev mode. +# Never set this in production. +NODE_TLS_REJECT_UNAUTHORIZED=0 diff --git a/package-lock.json b/package-lock.json index 8b661b6e..3de9d112 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,7 @@ "version": "0.0.1", "dependencies": { "@internationalized/date": "^3.11.0", - "monaco-editor": "^0.55.1", - "undici": "^7.22.0" + "monaco-editor": "^0.55.1" }, "devDependencies": { "@eslint/compat": "^2.0.2", @@ -4601,15 +4600,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/package.json b/package.json index c2022bb3..b5039491 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ }, "dependencies": { "@internationalized/date": "^3.11.0", - "monaco-editor": "^0.55.1", - "undici": "^7.22.0" + "monaco-editor": "^0.55.1" } } diff --git a/src/routes/api/trino/query/+server.ts b/src/routes/api/trino/query/+server.ts index d4f1776a..4745ce32 100644 --- a/src/routes/api/trino/query/+server.ts +++ b/src/routes/api/trino/query/+server.ts @@ -1,15 +1,10 @@ import { json } from '@sveltejs/kit'; -import { Agent, fetch as undiciFetch } from 'undici'; import type { RequestHandler } from './$types'; const POLL_TIMEOUT_MS = 30_000; const MAX_CACHED_ROWS = 100_000; const CACHE_TTL_MS = 5 * 60 * 1000; -const agent = new Agent({ - connect: { rejectUnauthorized: false } -}); - interface TrinoColumn { name: string; type: string; @@ -62,15 +57,14 @@ async function trinoFetch( auth: AuthConfig, options?: RequestInit ): Promise { - const res = await undiciFetch(url, { + const res = await fetch(url, { ...options, - dispatcher: agent, headers: { ...buildAuthHeaders(auth), 'X-Trino-Source': 'stackable-ui', ...(options?.headers ?? {}) } - } as Parameters[1]); + }); if (!res.ok) { const text = await res.text(); From 8e6f3338a4cce66dc0cc54f99f62287a9ee3b1c5 Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Wed, 25 Feb 2026 15:18:11 +0100 Subject: [PATCH 08/41] Document tech debt in new file --- AGENTS.md | 5 ++++ TECH_DEBT.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 TECH_DEBT.md diff --git a/AGENTS.md b/AGENTS.md index b8783f8d..9d61e3d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,8 +34,13 @@ This is a **single SvelteKit application** (not a monorepo). ├── static/ # Static assets ├── Dockerfile # Production container image └── CLAUDE.md # AI assistant instructions +└── TECH_DEBT.md # Known tech debt and deferred security concerns ``` +## Tech Debt + +When introducing shortcuts, known issues, or deferred security work, add an entry to `TECH_DEBT.md`. Keep entries concise: what the issue is, why it is acceptable now, and what the correct long-term fix is. + ## Development Guidelines ### Browser Compatibility diff --git a/TECH_DEBT.md b/TECH_DEBT.md new file mode 100644 index 00000000..0728eb69 --- /dev/null +++ b/TECH_DEBT.md @@ -0,0 +1,71 @@ +# Tech Debt + +Tracked issues that are acceptable at the current early stage but must be addressed before production. + +--- + +## Security + +### No authentication or authorisation on the Trino API route +**File:** `src/routes/api/trino/query/+server.ts`, `src/hooks.server.ts` + +The `/api/trino/query` endpoint is completely unauthenticated. Any request — from any origin — can execute arbitrary SQL against any Trino instance. OIDC authentication is planned (env vars are wired up, `hooks.server.ts` has the right structure) but not yet implemented. Until auth middleware is in place there is also no per-user rate limiting or query quota. + +--- + +### Trino credentials stored in localStorage +**File:** `src/routes/(app)/trino/+page.svelte:41–44` + +Username and password are persisted in plaintext localStorage. This is convenient for development (survives page reloads) but violates credential storage best practices — localStorage is accessible to any script on the page and visible in DevTools. Long-term the connection config should be stored server-side (tied to the authenticated session), with credentials never leaving the server after initial setup. + +--- + +### Credentials sent in every request body +**File:** `src/routes/(app)/trino/+page.svelte:82–84` + +Because there is no server-side session yet, connection credentials (including password) are included in the JSON body of every `/api/trino/query` POST. Once server-side sessions exist the client should send only a session token, not raw credentials. + +--- + +### Raw upstream error messages returned to the client +**File:** `src/routes/api/trino/query/+server.ts:165, 181, 209` + +Trino error messages and Node.js exception messages are returned to the browser without any sanitisation. Trino errors may expose schema details, table names, or internal query plans. These should be classified (query error vs. infrastructure error) and sanitised before being surfaced to users. + +--- + +## API & Validation + +### API route request body not validated with Zod +**File:** `src/routes/api/trino/query/+server.ts:83–103` + +`parseConnection` uses manual `typeof` checks instead of a Zod schema. The AGENTS.md guidelines require Zod for all validation. Additionally, `request.json()` is called without a try/catch — a malformed JSON body will throw an unhandled error rather than returning a 400. + +--- + +### In-memory query cache has no total size bound +**File:** `src/routes/api/trino/query/+server.ts:40–47` + +Each cached query can hold up to `MAX_CACHED_ROWS` (100 000) rows. `evictStale()` is only called when a new query arrives, not on a timer, so a long idle period followed by many concurrent queries could accumulate significant memory before eviction runs. Needs a bounded cache (e.g. LRU with a memory cap) and a periodic eviction timer. + +--- + +### Displayed results not cleared on connection change +**File:** `src/routes/(app)/trino/+page.svelte:40–46` + +The `$effect` that persists connection settings only resets `queryId`, not `rows`, `columns`, or `error`. After switching to a different Trino instance the previous result set remains visible until a new query is run, which is confusing. + +--- + +## Infrastructure + +### No Content Security Policy headers + +No CSP headers are set anywhere. This leaves the app exposed to XSS in ways that a strict CSP would mitigate. Should be added in a SvelteKit hook once the app stabilises. + +--- + +### `allowedHosts: true` in Vite config +**File:** `vite.config.ts` + +The dev server accepts requests from any host. This enables DNS rebinding attacks against local development environments. Should be restricted to `localhost` / `127.0.0.1` unless remote dev access is explicitly needed. From 94f2fdc06712f9cd216b71fda2536820c25bb56f Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Wed, 25 Feb 2026 15:32:50 +0100 Subject: [PATCH 09/41] disable all TLS validation when running as dev server --- .env.development | 4 ---- src/hooks.server.ts | 6 ++++++ src/routes/api/trino/query/+server.ts | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.env.development b/.env.development index ca62220a..e69de29b 100644 --- a/.env.development +++ b/.env.development @@ -1,4 +0,0 @@ -# Disable TLS certificate verification for development (e.g. self-signed Trino certs). -# This file is committed and loaded automatically in dev mode. -# Never set this in production. -NODE_TLS_REJECT_UNAUTHORIZED=0 diff --git a/src/hooks.server.ts b/src/hooks.server.ts index a8be42a8..2aa8f57d 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,6 +1,12 @@ import { paraglideMiddleware } from '$lib/paraglide/server'; import { type Handle } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; +import { dev } from '$app/environment'; + +// Allow self-signed TLS certificates in development (e.g. local Trino with self-signed certs). +if (dev) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; +} const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { diff --git a/src/routes/api/trino/query/+server.ts b/src/routes/api/trino/query/+server.ts index 4745ce32..91a191e1 100644 --- a/src/routes/api/trino/query/+server.ts +++ b/src/routes/api/trino/query/+server.ts @@ -199,7 +199,8 @@ export const POST: RequestHandler = async ({ request }) => { }); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; - console.error('[trino] unexpected error on %s: %s', connection.url, message); + const cause = err instanceof Error ? err.cause : undefined; + console.error('[trino] unexpected error on %s: %s', connection.url, message, cause ?? ''); return json({ error: message }, { status: 500 }); } }; From 3c9bec445f1867babfa293e1c05a157b1a39faf3 Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Wed, 25 Feb 2026 17:26:11 +0100 Subject: [PATCH 10/41] Use pino for logs and add missing i18n --- messages/de.json | 5 ++- messages/en.json | 5 ++- src/routes/api/trino/query/+server.ts | 47 ++++++++++++--------------- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/messages/de.json b/messages/de.json index 526912cf..3766154d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -57,5 +57,8 @@ "trino_auth_basic": "Basic", "trino_auth_username": "Benutzername", "trino_auth_password": "Passwort", - "trino_unknown_error": "Unbekannter Fehler" + "trino_unknown_error": "Unbekannter Fehler", + "trino_no_sql": "Keine SQL-Abfrage angegeben", + "trino_invalid_connection": "Die Verbindungskonfiguration ist ungültig oder fehlt. Bitte eine gültige URL und Authentifizierungsmethode angeben.", + "trino_query_timeout": "Abfrage nach 30 Sekunden abgebrochen" } diff --git a/messages/en.json b/messages/en.json index 5c22111b..17fd1fda 100644 --- a/messages/en.json +++ b/messages/en.json @@ -57,5 +57,8 @@ "trino_auth_basic": "Basic", "trino_auth_username": "Username", "trino_auth_password": "Password", - "trino_unknown_error": "Unknown error" + "trino_unknown_error": "Unknown error", + "trino_no_sql": "No SQL provided", + "trino_invalid_connection": "The connection configuration is invalid or missing. Please provide a valid URL and authentication method.", + "trino_query_timeout": "Query timed out after 30 seconds" } diff --git a/src/routes/api/trino/query/+server.ts b/src/routes/api/trino/query/+server.ts index 91a191e1..10b78035 100644 --- a/src/routes/api/trino/query/+server.ts +++ b/src/routes/api/trino/query/+server.ts @@ -1,4 +1,5 @@ import { json } from '@sveltejs/kit'; +import * as m from '$lib/paraglide/messages.js'; import type { RequestHandler } from './$types'; const POLL_TIMEOUT_MS = 30_000; @@ -98,7 +99,9 @@ function parseConnection(raw: unknown): ConnectionConfig | null { const ALLOWED_PAGE_SIZES = new Set([25, 50, 100]); -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, locals }) => { + const log = locals.logger; + const body = await request.json(); const pageSize: number = ALLOWED_PAGE_SIZES.has(body.pageSize) ? body.pageSize : 25; const page: number = typeof body.page === 'number' && body.page >= 0 ? Math.floor(body.page) : 0; @@ -107,11 +110,11 @@ export const POST: RequestHandler = async ({ request }) => { if (typeof body.queryId === 'string' && body.queryId) { const cached = queryCache.get(body.queryId); if (!cached) { - console.info('[trino] cache miss for queryId=%s (session expired)', body.queryId); + log.info({ query_id: body.queryId }, 'cache miss (session expired)'); return json({ error: 'session_expired' }, { status: 404 }); } const start = page * pageSize; - console.debug('[trino] cache hit queryId=%s page=%d pageSize=%d', body.queryId, page, pageSize); + log.debug({ query_id: body.queryId, page, page_size: pageSize }, 'cache hit'); return json({ queryId: body.queryId, columns: cached.columns, @@ -124,26 +127,20 @@ export const POST: RequestHandler = async ({ request }) => { // New query execution — connection config required. const { sql } = body; if (!sql?.trim()) { - return json({ error: 'No SQL provided' }, { status: 400 }); + return json({ error: m.trino_no_sql() }, { status: 400 }); } const connection = parseConnection(body.connection); if (!connection) { - return json( - { - error: - 'Invalid or missing connection config. Provide connection.url (http/https) and connection.auth.' - }, - { status: 400 } - ); + return json({ error: m.trino_invalid_connection() }, { status: 400 }); } evictStale(); - const start = Date.now(); - console.info('[trino] executing query on %s', connection.url); + const queryStart = Date.now(); + log.info({ trino_url: connection.url }, 'executing query'); - const deadline = start + POLL_TIMEOUT_MS; + const deadline = queryStart + POLL_TIMEOUT_MS; let columns: TrinoColumn[] = []; let rows: unknown[][] = []; @@ -155,7 +152,7 @@ export const POST: RequestHandler = async ({ request }) => { }); if (response.error) { - console.info('[trino] query error: %s', response.error.message); + log.info({ err: response.error }, 'query error'); return json({ error: response.error.message }, { status: 400 }); } @@ -164,14 +161,14 @@ export const POST: RequestHandler = async ({ request }) => { while (response.nextUri && rows.length < MAX_CACHED_ROWS) { if (Date.now() > deadline) { - console.info('[trino] query timed out after %dms on %s', POLL_TIMEOUT_MS, connection.url); - return json({ error: 'Query timed out after 30 seconds' }, { status: 408 }); + log.info({ trino_url: connection.url, timeout_ms: POLL_TIMEOUT_MS }, 'query timed out'); + return json({ error: m.trino_query_timeout() }, { status: 408 }); } response = await trinoFetch(response.nextUri, connection.auth); if (response.error) { - console.info('[trino] query error: %s', response.error.message); + log.info({ err: response.error }, 'query error'); return json({ error: response.error.message }, { status: 400 }); } @@ -182,12 +179,9 @@ export const POST: RequestHandler = async ({ request }) => { const queryId = crypto.randomUUID(); queryCache.set(queryId, { columns, rows, createdAt: Date.now() }); - console.info( - '[trino] query complete: %d rows, %d cols, %dms, queryId=%s', - rows.length, - columns.length, - Date.now() - start, - queryId + log.info( + { query_id: queryId, rows: rows.length, cols: columns.length, duration_ms: Date.now() - queryStart }, + 'query complete' ); return json({ @@ -198,9 +192,8 @@ export const POST: RequestHandler = async ({ request }) => { totalRows: rows.length }); } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - const cause = err instanceof Error ? err.cause : undefined; - console.error('[trino] unexpected error on %s: %s', connection.url, message, cause ?? ''); + log.error({ err, trino_url: connection.url }, 'unexpected error'); + const message = err instanceof Error ? err.message : m.trino_unknown_error(); return json({ error: message }, { status: 500 }); } }; From 033bf567d6554a32fdbda31c56d2882a38056ca4 Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Thu, 26 Feb 2026 21:22:14 +0100 Subject: [PATCH 11/41] Move trino query execution to server-side form action --- e2e/trino.spec.ts | 302 +++++++++--------- messages/de.json | 1 + messages/en.json | 1 + src/lib/server/trino.ts | 69 +++++ src/routes/(app)/trino/+page.server.ts | 134 ++++++++ src/routes/(app)/trino/+page.svelte | 407 ++++++++++++++----------- src/routes/(app)/trino/schemas.ts | 30 ++ src/routes/api/trino/query/+server.ts | 199 ------------ 8 files changed, 626 insertions(+), 517 deletions(-) create mode 100644 src/lib/server/trino.ts create mode 100644 src/routes/(app)/trino/+page.server.ts create mode 100644 src/routes/(app)/trino/schemas.ts delete mode 100644 src/routes/api/trino/query/+server.ts diff --git a/e2e/trino.spec.ts b/e2e/trino.spec.ts index 77afb1d0..cffebbcb 100644 --- a/e2e/trino.spec.ts +++ b/e2e/trino.spec.ts @@ -1,4 +1,6 @@ import { test, expect, type Page } from '@playwright/test'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; const COLUMNS = [ { name: 'id', type: 'integer' }, @@ -15,6 +17,26 @@ async function waitForHydration(page: Page) { .toMatch(/^(light|dark)$/); } +// Starts a lightweight HTTP server that acts as a mock Trino endpoint. +// The server action fetches `{trino_url}/v1/statement` from the SvelteKit server +// (Node.js), so we need a real TCP server reachable by the server process. +// page.route() only intercepts browser-side requests and cannot mock server-side +// Node.js fetch calls. +async function startMockTrinoServer( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void +): Promise<{ url: string; stop: () => Promise }> { + const server = http.createServer(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + return { + url: `http://127.0.0.1:${port}`, + stop: () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ) + }; +} + test.describe('Trino query editor', () => { test.use({ locale: 'en-US' }); @@ -46,161 +68,147 @@ test.describe('Trino query editor', () => { }); test('running a query displays the results table', async ({ page }) => { - await page.route('**/api/trino/query', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - queryId: 'q1', + const { url, stop } = await startMockTrinoServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'test-query-id', columns: COLUMNS, - rows: [ + data: [ [1, 'Alice'], [2, 'Bob'], [3, 'Carol'] ], - hasMore: false, - totalRows: 3 + stats: { state: 'FINISHED' } }) - }); + ); }); - - await page.goto('/trino'); - await waitForHydration(page); - await page.getByRole('button', { name: 'Run query' }).click(); - - const table = page.getByRole('table', { name: 'Query results' }); - await expect(table).toBeVisible(); - await expect(table.getByRole('columnheader', { name: 'id' })).toBeVisible(); - await expect(table.getByRole('columnheader', { name: 'name' })).toBeVisible(); - await expect(table.getByRole('cell', { name: '1' })).toBeVisible(); - await expect(table.getByRole('cell', { name: 'Alice' })).toBeVisible(); - await expect(page.getByText('Rows 1–3 of 3')).toBeVisible(); + await page.addInitScript((trinoUrl) => { + localStorage.setItem('trino_url', trinoUrl); + }, url); + + try { + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Run query' }).click(); + + const table = page.getByRole('table', { name: 'Query results' }); + await expect(table).toBeVisible(); + await expect(table.getByRole('columnheader', { name: 'id' })).toBeVisible(); + await expect(table.getByRole('columnheader', { name: 'name' })).toBeVisible(); + await expect(table.getByRole('cell', { name: '1' })).toBeVisible(); + await expect(table.getByRole('cell', { name: 'Alice' })).toBeVisible(); + await expect(page.getByText('Rows 1–3 of 3')).toBeVisible(); + } finally { + await stop(); + } }); test('Ctrl+Enter triggers query execution', async ({ page }) => { let called = false; - await page.route('**/api/trino/query', async (route) => { + const { url, stop } = await startMockTrinoServer((_req, res) => { called = true; - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - queryId: 'q1', + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'q1', columns: COLUMNS, - rows: [[1, 'Alice']], - hasMore: false, - totalRows: 1 + data: [[1, 'Alice']], + stats: { state: 'FINISHED' } }) - }); + ); }); - - await page.goto('/trino'); - await waitForHydration(page); - await page.keyboard.press('Control+Enter'); - - await expect.poll(() => called).toBe(true); - await expect(page.getByRole('table', { name: 'Query results' })).toBeVisible(); + await page.addInitScript((trinoUrl) => { + localStorage.setItem('trino_url', trinoUrl); + }, url); + + try { + await page.goto('/trino'); + await waitForHydration(page); + await page.keyboard.press('Control+Enter'); + + await expect.poll(() => called).toBe(true); + await expect(page.getByRole('table', { name: 'Query results' })).toBeVisible(); + } finally { + await stop(); + } }); test('query error is shown in an alert', async ({ page }) => { - await page.route('**/api/trino/query', async (route) => { - await route.fulfill({ - status: 400, - contentType: 'application/json', - body: JSON.stringify({ error: 'syntax error at position 7' }) - }); + const { url, stop } = await startMockTrinoServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'q-err', + error: { message: 'syntax error at position 7', errorCode: 1 }, + stats: { state: 'FAILED' } + }) + ); }); - - await page.goto('/trino'); - await waitForHydration(page); - await page.getByRole('button', { name: 'Run query' }).click(); - - // Monaco also renders role="alert" nodes for its own accessibility — filter by content. - const alert = page.getByRole('alert').filter({ hasText: 'Query error' }); - await expect(alert).toBeVisible(); - await expect(alert.getByText('syntax error at position 7')).toBeVisible(); + await page.addInitScript((trinoUrl) => { + localStorage.setItem('trino_url', trinoUrl); + }, url); + + try { + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Run query' }).click(); + + // Monaco also renders role="alert" nodes for its own accessibility — filter by content. + const alert = page.getByRole('alert').filter({ hasText: 'Query error' }); + await expect(alert).toBeVisible(); + await expect(alert.getByText('syntax error at position 7')).toBeVisible(); + } finally { + await stop(); + } }); test('pagination navigates between pages', async ({ page }) => { + // The server action caches all rows after the first query; + // prev/next page requests are served from the cache without hitting Trino again. const allRows = Array.from({ length: 30 }, (_, i) => [i + 1, `Row ${i + 1}`]); - - await page.route('**/api/trino/query', async (route) => { - const body = JSON.parse((await route.request().postData()) ?? '{}'); - const pg: number = body.page ?? 0; - const ps = 25; - const slice = allRows.slice(pg * ps, pg * ps + ps); - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - queryId: 'q-pages', + const { url, stop } = await startMockTrinoServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'q-pages', columns: COLUMNS, - rows: slice, - hasMore: (pg + 1) * ps < allRows.length, - totalRows: allRows.length + data: allRows, + stats: { state: 'FINISHED' } }) - }); - }); - - await page.goto('/trino'); - await waitForHydration(page); - await page.getByRole('button', { name: 'Run query' }).click(); - - await expect(page.getByText('Rows 1–25 of 30')).toBeVisible(); - - const nextBtn = page.getByRole('button', { name: 'Next page' }); - const prevBtn = page.getByRole('button', { name: 'Previous page' }); - await expect(prevBtn).toBeDisabled(); - await expect(nextBtn).toBeEnabled(); - - await nextBtn.click(); - await expect(page.getByText('Rows 26–30 of 30')).toBeVisible(); - await expect(prevBtn).toBeEnabled(); - await expect(nextBtn).toBeDisabled(); - - await prevBtn.click(); - await expect(page.getByText('Rows 1–25 of 30')).toBeVisible(); - }); - - test('session expired shows correct message', async ({ page }) => { - let call = 0; - await page.route('**/api/trino/query', async (route) => { - call++; - if (call === 1) { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - queryId: 'exp-id', - columns: COLUMNS, - rows: [[1, 'Alice']], - hasMore: true, - totalRows: 50 - }) - }); - } else { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ error: 'session_expired' }) - }); - } + ); }); - - await page.goto('/trino'); - await waitForHydration(page); - await page.getByRole('button', { name: 'Run query' }).click(); - await expect(page.getByRole('table')).toBeVisible(); - - await page.getByRole('button', { name: 'Next page' }).click(); - - const alert = page.getByRole('alert').filter({ hasText: 'Query session expired' }); - await expect(alert).toBeVisible(); + await page.addInitScript((trinoUrl) => { + localStorage.setItem('trino_url', trinoUrl); + }, url); + + try { + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Run query' }).click(); + + await expect(page.getByText('Rows 1–25 of 30')).toBeVisible(); + + const nextBtn = page.getByRole('button', { name: 'Next page' }); + const prevBtn = page.getByRole('button', { name: 'Previous page' }); + await expect(prevBtn).toBeDisabled(); + await expect(nextBtn).toBeEnabled(); + + await nextBtn.click(); + await expect(page.getByText('Rows 26–30 of 30')).toBeVisible(); + await expect(prevBtn).toBeEnabled(); + await expect(nextBtn).toBeDisabled(); + + await prevBtn.click(); + await expect(page.getByText('Rows 1–25 of 30')).toBeVisible(); + } finally { + await stop(); + } }); test('connection section expands to reveal URL and auth controls', async ({ page }) => { await page.goto('/trino'); + await waitForHydration(page); // The DaisyUI collapse uses a visually-hidden checkbox as its toggle. await page.getByRole('checkbox', { name: 'Connection' }).check({ force: true }); @@ -212,6 +220,7 @@ test.describe('Trino query editor', () => { test('switching to basic auth reveals credential fields', async ({ page }) => { await page.goto('/trino'); + await waitForHydration(page); await page.getByRole('checkbox', { name: 'Connection' }).check({ force: true }); // No credentials visible for 'none' auth @@ -226,27 +235,32 @@ test.describe('Trino query editor', () => { }); test('null cell values render as italic null placeholder', async ({ page }) => { - await page.route('**/api/trino/query', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - queryId: 'q-null', + const { url, stop } = await startMockTrinoServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'q-null', columns: [{ name: 'value', type: 'varchar' }], - rows: [[null], ['hello']], - hasMore: false, - totalRows: 2 + data: [[null], ['hello']], + stats: { state: 'FINISHED' } }) - }); + ); }); - - await page.goto('/trino'); - await waitForHydration(page); - await page.getByRole('button', { name: 'Run query' }).click(); - - const table = page.getByRole('table'); - await expect(table).toBeVisible(); - await expect(table.getByText('null')).toBeVisible(); - await expect(table.getByText('hello')).toBeVisible(); + await page.addInitScript((trinoUrl) => { + localStorage.setItem('trino_url', trinoUrl); + }, url); + + try { + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Run query' }).click(); + + const table = page.getByRole('table'); + await expect(table).toBeVisible(); + await expect(table.getByText('null')).toBeVisible(); + await expect(table.getByText('hello')).toBeVisible(); + } finally { + await stop(); + } }); }); diff --git a/messages/de.json b/messages/de.json index 3766154d..25853685 100644 --- a/messages/de.json +++ b/messages/de.json @@ -51,6 +51,7 @@ "trino_prev_page": "Vorherige Seite", "trino_next_page": "Nächste Seite", "trino_connection_label": "Verbindung", + "trino_connection_error": "Bitte konfigurieren Sie eine gültige Verbindungs-URL.", "trino_connection_url": "URL", "trino_connection_auth": "Authentifizierung", "trino_auth_none": "Keine", diff --git a/messages/en.json b/messages/en.json index 17fd1fda..536029d5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -51,6 +51,7 @@ "trino_prev_page": "Previous page", "trino_next_page": "Next page", "trino_connection_label": "Connection", + "trino_connection_error": "Please configure a valid connection URL.", "trino_connection_url": "URL", "trino_connection_auth": "Auth", "trino_auth_none": "No auth", diff --git a/src/lib/server/trino.ts b/src/lib/server/trino.ts new file mode 100644 index 00000000..66a89c7f --- /dev/null +++ b/src/lib/server/trino.ts @@ -0,0 +1,69 @@ +export interface TrinoColumn { + name: string; + type: string; +} + +export interface TrinoResponse { + id?: string; + nextUri?: string; + columns?: TrinoColumn[]; + data?: unknown[][]; + stats?: { state: string }; + error?: { message: string; errorCode: number }; +} + +export interface CacheEntry { + columns: TrinoColumn[]; + rows: unknown[][]; + createdAt: number; +} + +export type AuthConfig = + | { type: 'none' } + | { type: 'basic'; username: string; password: string }; + +export const POLL_TIMEOUT_MS = 30_000; +export const MAX_CACHED_ROWS = 100_000; +export const CACHE_TTL_MS = 5 * 60 * 1000; + +export const queryCache = new Map(); + +export function evictStale() { + const cutoff = Date.now() - CACHE_TTL_MS; + for (const [id, entry] of queryCache) { + if (entry.createdAt < cutoff) queryCache.delete(id); + } +} + +export function buildAuthHeaders(auth: AuthConfig): Record { + if (auth.type === 'basic') { + const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64'); + return { + 'X-Trino-User': auth.username, + Authorization: `Basic ${encoded}` + }; + } + return { 'X-Trino-User': 'anonymous' }; +} + +export async function trinoFetch( + url: string, + auth: AuthConfig, + options?: RequestInit +): Promise { + const res = await fetch(url, { + ...options, + headers: { + ...buildAuthHeaders(auth), + 'X-Trino-Source': 'stackable-ui', + ...(options?.headers ?? {}) + } + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Trino HTTP ${res.status}: ${text}`); + } + + return res.json() as Promise; +} diff --git a/src/routes/(app)/trino/+page.server.ts b/src/routes/(app)/trino/+page.server.ts new file mode 100644 index 00000000..29503e2d --- /dev/null +++ b/src/routes/(app)/trino/+page.server.ts @@ -0,0 +1,134 @@ +import { fail } from '@sveltejs/kit'; +import { superValidate, message } from 'sveltekit-superforms'; +import { zod4 as zod } from 'sveltekit-superforms/adapters'; +import * as m from '$lib/paraglide/messages.js'; +import { + trinoFetch, + evictStale, + queryCache, + POLL_TIMEOUT_MS, + MAX_CACHED_ROWS, + type AuthConfig, + type TrinoColumn +} from '$lib/server/trino.js'; +import { QuerySchema, PaginateSchema, type FormMessage } from './schemas.js'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + locals.logger.debug('loading Trino page'); + const [queryForm, paginateForm] = await Promise.all([ + superValidate(zod(QuerySchema)), + superValidate(zod(PaginateSchema)) + ]); + return { queryForm, paginateForm }; +}; + +export const actions: Actions = { + query: async ({ request, locals }) => { + const log = locals.logger; + const form = await superValidate(request, zod(QuerySchema)); + + if (!form.valid) { + log.debug({ errors: form.errors }, 'query form validation failed'); + return fail(400, { form }); + } + + const { sql, pageSize, connectionUrl, authType, authUsername, authPassword } = form.data; + const auth: AuthConfig = + authType === 'basic' + ? { type: 'basic', username: authUsername, password: authPassword } + : { type: 'none' }; + + evictStale(); + + const queryStart = Date.now(); + log.info({ trino_url: connectionUrl }, 'executing query'); + + const deadline = queryStart + POLL_TIMEOUT_MS; + let columns: TrinoColumn[] = []; + let rows: unknown[][] = []; + + try { + let response = await trinoFetch(`${connectionUrl}/v1/statement`, auth, { + method: 'POST', + body: sql.replace(/;\s*$/, '').trim(), + headers: { 'Content-Type': 'text/plain' } + }); + + if (response.error) { + log.info({ err: response.error }, 'query error'); + return message(form, { type: 'error', message: response.error.message } satisfies FormMessage, { status: 400 }); + } + + if (response.columns) columns = response.columns; + if (response.data) rows = rows.concat(response.data); + + while (response.nextUri && rows.length < MAX_CACHED_ROWS) { + if (Date.now() > deadline) { + log.info({ trino_url: connectionUrl, timeout_ms: POLL_TIMEOUT_MS }, 'query timed out'); + return message(form, { type: 'error', message: m.trino_query_timeout() } satisfies FormMessage, { status: 408 }); + } + + response = await trinoFetch(response.nextUri, auth); + + if (response.error) { + log.info({ err: response.error }, 'query error'); + return message(form, { type: 'error', message: response.error.message } satisfies FormMessage, { status: 400 }); + } + + if (response.columns && columns.length === 0) columns = response.columns; + if (response.data) rows = rows.concat(response.data); + } + + const queryId = crypto.randomUUID(); + queryCache.set(queryId, { columns, rows, createdAt: Date.now() }); + + log.info( + { query_id: queryId, rows: rows.length, cols: columns.length, duration_ms: Date.now() - queryStart }, + 'query complete' + ); + + return message(form, { + type: 'result', + queryId, + columns, + rows: rows.slice(0, pageSize), + hasMore: rows.length > pageSize, + totalRows: rows.length + } satisfies FormMessage); + } catch (err) { + log.error({ err, trino_url: connectionUrl }, 'unexpected error'); + const msg = err instanceof Error ? err.message : m.trino_unknown_error(); + return message(form, { type: 'error', message: msg } satisfies FormMessage, { status: 500 }); + } + }, + + paginate: async ({ request, locals }) => { + const log = locals.logger; + const form = await superValidate(request, zod(PaginateSchema)); + + if (!form.valid) { + return fail(400, { form }); + } + + const { queryId, page, pageSize } = form.data; + const cached = queryCache.get(queryId); + + if (!cached) { + log.info({ query_id: queryId }, 'cache miss (session expired)'); + return message(form, { type: 'error', message: 'session_expired' } satisfies FormMessage, { status: 404 }); + } + + const start = page * pageSize; + log.debug({ query_id: queryId, page, page_size: pageSize }, 'cache hit'); + + return message(form, { + type: 'result', + queryId, + columns: cached.columns, + rows: cached.rows.slice(start, start + pageSize), + hasMore: start + pageSize < cached.rows.length, + totalRows: cached.rows.length + } satisfies FormMessage); + } +}; diff --git a/src/routes/(app)/trino/+page.svelte b/src/routes/(app)/trino/+page.svelte index fc3aa90f..332c4c9a 100644 --- a/src/routes/(app)/trino/+page.svelte +++ b/src/routes/(app)/trino/+page.svelte @@ -1,9 +1,13 @@
- -
- -
- {m.trino_connection_label()} - {connectionSummary()} -
-
- -
- - -
+ +
+ + + + + + + - -
- {m.trino_connection_auth()} -
- + +
+ +
+ {m.trino_connection_label()} + {connectionSummary()} +
+
+ +
+ + {#if $queryErrors.connectionUrl} +

{$queryErrors.connectionUrl?.join(' ')}

+ {/if}
-
- - {#if authType === 'basic'} -
-
- + +
+ {m.trino_connection_auth()} +
-
-
-
- {/if} -
-
- -
-
- {m.trino_editor_label()} - +
-
- + + +
+
+ {m.trino_editor_label()} + +
+
+ queryFormEl?.requestSubmit()} + /> +
-
+ + + +
@@ -258,7 +317,7 @@
- {#if error} + {#if queryError} {:else if columns.length > 0} @@ -314,7 +373,7 @@
diff --git a/src/routes/(app)/trino/schemas.ts b/src/routes/(app)/trino/schemas.ts index d66fa9c3..e88456d0 100644 --- a/src/routes/(app)/trino/schemas.ts +++ b/src/routes/(app)/trino/schemas.ts @@ -9,7 +9,11 @@ export const QuerySchema = z.object({ connectionUrl: z.string().url(), authType: z.enum(['none', 'basic']).default('none'), authUsername: z.string().default(''), - authPassword: z.string().default('') + authPassword: z.string().default(''), + impersonation: z + .string() + .default('false') + .transform((v) => v === 'true') }); export const PaginateSchema = z.object({ diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 277fe8ab..3b77780c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -11,7 +11,10 @@ const observer = new MutationObserver(() => { theme.current = document.documentElement.dataset.theme ?? 'dark'; }); - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'] + }); return () => observer.disconnect(); }); From 7891715e64325e3ecf36f2bd1b865e8fe9b8d404 Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Mon, 2 Mar 2026 16:37:56 +0100 Subject: [PATCH 15/41] Switch Trino query form to JSON dataType --- .env.development | 6 -- .gitignore | 2 + src/lib/components/layout/Header.svelte | 6 +- src/routes/(app)/trino/+page.server.ts | 41 ++++----- src/routes/(app)/trino/+page.svelte | 116 ++++++++++++++---------- src/routes/(app)/trino/schemas.ts | 5 +- src/routes/auth/logout/+page.server.ts | 8 +- 7 files changed, 98 insertions(+), 86 deletions(-) delete mode 100644 .env.development diff --git a/.env.development b/.env.development deleted file mode 100644 index cc3cacd8..00000000 --- a/.env.development +++ /dev/null @@ -1,6 +0,0 @@ -STACKABLE_UI_SQLITE_PATH=.data/auth.db -STACKABLE_UI_OIDC_DISCOVERY_URL=http://172.18.0.2:30080/realms/stackable/.well-known/openid-configuration -STACKABLE_UI_OIDC_CLIENT_ID=stackable-ui -STACKABLE_UI_OIDC_CLIENT_SECRET=dxrO7CkZBo1dP9ksi74YiEe9eOhXlt0A -STACKABLE_UI_SESSION_SECRET=3e19091b882c3f115ddac9a0be3ad3257476a719cc9a83f26abb22a0ccd9a895 -STACKABLE_UI_BASE_URL=http://localhost:5173 diff --git a/.gitignore b/.gitignore index a0aca393..aa194a00 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ Thumbs.db # Env .env +.env.development +.env.development.bak !.env.example !.env.test diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte index 06e09187..243e6d35 100644 --- a/src/lib/components/layout/Header.svelte +++ b/src/lib/components/layout/Header.svelte @@ -2,9 +2,9 @@ import * as m from '$lib/paraglide/messages.js'; import LanguageSwitcher from './LanguageSwitcher.svelte'; import ThemeToggle from './ThemeToggle.svelte'; - import type { auth } from '$lib/server/auth'; + import { authClient } from '$lib/auth-client'; - type User = typeof auth.$Infer.Session.user | null; + type User = typeof authClient.$Infer.Session.user; let { title = m.page_title_dashboard(), @@ -14,7 +14,7 @@ }: { title?: string; mobileOpen?: boolean; - user?: User; + user?: User | null; onToggleMobile?: () => void; } = $props(); diff --git a/src/routes/(app)/trino/+page.server.ts b/src/routes/(app)/trino/+page.server.ts index 67c2a2cb..5df717f3 100644 --- a/src/routes/(app)/trino/+page.server.ts +++ b/src/routes/(app)/trino/+page.server.ts @@ -9,7 +9,8 @@ import { POLL_TIMEOUT_MS, MAX_CACHED_ROWS, type AuthConfig, - type TrinoColumn + type TrinoColumn, + type TrinoResponse } from '$lib/server/trino.js'; import { QuerySchema, PaginateSchema, type FormMessage } from './schemas.js'; import type { Actions, PageServerLoad } from './$types'; @@ -48,29 +49,17 @@ export const actions: Actions = { const deadline = queryStart + POLL_TIMEOUT_MS; let columns: TrinoColumn[] = []; - let rows: unknown[][] = []; + const rows: unknown[][] = []; + let nextUri: string | undefined = `${connectionUrl}/v1/statement`; + let fetchOptions: RequestInit & { impersonateUser?: string } = { + method: 'POST', + body: sql.replace(/;\s*$/, '').trim(), + headers: { 'Content-Type': 'text/plain' }, + impersonateUser + }; try { - let response = await trinoFetch(`${connectionUrl}/v1/statement`, auth, { - method: 'POST', - body: sql.replace(/;\s*$/, '').trim(), - headers: { 'Content-Type': 'text/plain' }, - impersonateUser - }); - - if (response.error) { - log.info({ err: response.error }, 'query error'); - return message( - form, - { type: 'error', message: response.error.message } satisfies FormMessage, - { status: 400 } - ); - } - - if (response.columns) columns = response.columns; - if (response.data) rows = rows.concat(response.data); - - while (response.nextUri && rows.length < MAX_CACHED_ROWS) { + while (nextUri && rows.length < MAX_CACHED_ROWS) { if (Date.now() > deadline) { log.info({ trino_url: connectionUrl, timeout_ms: POLL_TIMEOUT_MS }, 'query timed out'); return message( @@ -80,7 +69,7 @@ export const actions: Actions = { ); } - response = await trinoFetch(response.nextUri, auth, { impersonateUser }); + const response: TrinoResponse = await trinoFetch(nextUri, auth, fetchOptions); if (response.error) { log.info({ err: response.error }, 'query error'); @@ -92,7 +81,11 @@ export const actions: Actions = { } if (response.columns && columns.length === 0) columns = response.columns; - if (response.data) rows = rows.concat(response.data); + if (response.data) rows.push(...response.data); + + nextUri = response.nextUri; + // Subsequent requests are GETs to the nextUri + fetchOptions = { impersonateUser }; } const queryId = crypto.randomUUID(); diff --git a/src/routes/(app)/trino/+page.svelte b/src/routes/(app)/trino/+page.svelte index 49a0791f..b76b490d 100644 --- a/src/routes/(app)/trino/+page.svelte +++ b/src/routes/(app)/trino/+page.svelte @@ -1,4 +1,5 @@