Migrate to SvelteKit 3 #71

Merged
eleith merged 4 commits from feature/sveltekit-3 into main 2026-11-03 16:55:19 +00:00
57 changed files with 690 additions and 883 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'@directory/web': patch
---
Upgrade the web app to SvelteKit 3, preserving runtime `site.url`, native sign-in, bookmark navigation and container workflows.
+3 -1
View File
@@ -22,9 +22,11 @@ COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile
# Only generated output and caches are writable; source binds stay read-only.
RUN mkdir -p /m/packages/config/dist /m/apps/web/.svelte-kit \
'/m/apps/web/node_modules/$app' \
/m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp \
&& chown -R ${LOCAL_UID}:${LOCAL_GID} /home/app /m/packages/config/dist \
/m/apps/web/.svelte-kit /m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp
/m/apps/web/.svelte-kit '/m/apps/web/node_modules/$app' \
/m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp
USER ${LOCAL_UID}:${LOCAL_GID}
WORKDIR /m/apps/web
EXPOSE 5173
-1
View File
@@ -26,7 +26,6 @@ services:
- ./static:/m/apps/web/static:ro
- ./config:/m/apps/web/config:ro
- ./public:/m/apps/web/public:ro
- ./svelte.config.js:/m/apps/web/svelte.config.js:ro
- ./vite.config.ts:/m/apps/web/vite.config.ts:ro
- ./tsconfig.json:/m/apps/web/tsconfig.json:ro
- ../../packages/config/src:/m/packages/config/src:ro
+19 -10
View File
@@ -99,7 +99,9 @@ for (const view of ['card', 'details'] as const) {
await page.goto(`${server.url}/${view === 'details' ? '?link=broken' : ''}`, {
waitUntil: 'commit'
});
const cardImage = page.locator('a[href="/?link=broken"] img');
const cardImage = page
.getByRole('link', { name: 'Broken primary', exact: true })
.locator('img');
await expect(cardImage).toHaveAttribute('src', '/public/primary-a.svg');
await expectBroken(cardImage);
await expect.poll(() => heldJavaScript).toBe(true);
@@ -266,16 +268,23 @@ test('without JavaScript, good images decode and broken images leave card and de
['default', 'Omitted primary'],
['other', 'Other broken primary']
]) {
const card = page.locator(`a[href="/?link=${id}"]`);
await expect(card).toContainText(title!);
await expect(card).toHaveAttribute('data-sveltekit-preload-data', 'off');
const card = page.getByRole('link', { name: title!, exact: true });
await expect(card).toHaveJSProperty('href', `${server.url}/?link=${id}`);
await expect(card).toHaveAttribute('data-sveltekit-preload-data', 'false');
}
const broken = page.locator('a[href="/?link=broken"] img');
const brokenLink = page.getByRole('link', { name: 'Broken primary', exact: true });
const broken = brokenLink.locator('img');
await expect(broken).toHaveAttribute('src', '/public/primary-a.svg');
await expectBroken(broken);
await expectDecoded(page.locator('a[href="/?link=valid"] img'), '/public/valid.svg');
await expectDecoded(page.locator('a[href="/?link=default"] img'), '/bookmarks/default/logo');
await page.locator('a[href="/?link=broken"]').click();
await expectDecoded(
page.getByRole('link', { name: 'Valid primary', exact: true }).locator('img'),
'/public/valid.svg'
);
await expectDecoded(
page.getByRole('link', { name: 'Omitted primary', exact: true }).locator('img'),
'/bookmarks/default/logo'
);
await brokenLink.click();
const details = page.getByRole('region', { name: 'Broken primary', exact: true });
await expect(details.locator('img')).toHaveAttribute('src', '/public/primary-a.svg');
await expectBroken(details.locator('img'));
@@ -299,9 +308,9 @@ test('without JavaScript, good images decode and broken images leave card and de
await expect(url).toBeVisible();
await expect(url).toHaveValue('https://broken.example.test');
await expect(url).toHaveJSProperty('readOnly', true);
await page.locator('a[href="/?link=valid"]').click();
await page.getByRole('link', { name: 'Valid primary', exact: true }).click();
await expectDecoded(page.locator('section.details img'), '/public/valid.svg');
await page.locator('a[href="/?link=default"]').click();
await page.getByRole('link', { name: 'Omitted primary', exact: true }).click();
await expectDecoded(page.locator('section.details img'), '/bookmarks/default/logo');
await page.getByRole('link', { name: 'Back to directory' }).click();
await expect(page).toHaveURL(`${server.url}/`);
+3 -2
View File
@@ -26,14 +26,15 @@ test('bookmark logos render on cards and details without JavaScript and follow a
await server.ready();
const page = await context.newPage();
await page.goto(server.url);
const card = page.locator('a[href="/?link=photos"] img');
const photos = page.getByRole('link', { name: 'Family Photos', exact: true });
const card = photos.locator('img');
await expect(card).toHaveAttribute('src', '/bookmarks/photos/logo');
await expect(card).toHaveAttribute('width', '64');
await expect(card).toHaveAttribute('height', '64');
await expect
.poll(() => card.evaluate((image) => (image as HTMLImageElement).naturalWidth))
.toBeGreaterThan(0);
await page.locator('a[href="/?link=photos"]').click();
await photos.click();
const details = page.getByRole('region', { name: 'Family Photos', exact: true });
await expect(details.locator('img')).toHaveAttribute('src', '/bookmarks/photos/logo');
expect(await (await server.fetch('/bookmarks/photos/logo')).text()).toContain('>FP</text>');
+7 -3
View File
@@ -281,7 +281,11 @@ test('open mode serves contained images regardless of references, with MIME and
expectPrivateHeaders(response);
}
}
expect((await server.fetch('/public/photo.svg', { method: 'POST' })).status).toBe(405);
const unsupported = await server.fetch('/public/photo.svg', {
method: 'POST',
headers: { origin: server.origin }
});
expect(unsupported.status).toBe(405);
const generated = await server.fetch('/bookmarks/photos/logo');
expect(generated.status).toBe(200);
expectPrivateHeaders(generated);
@@ -413,13 +417,13 @@ test('existing SSR cards and details render mounted logos and omitted-path bookm
['router', '/bookmarks/router/logo'],
['thumbnail', '/public/photo.png']
] as const) {
const image = page.locator(`a[href="/?link=${id}"] img`);
const image = page.locator(`a[href$="?link=${id}"] img`);
await expect(image).toHaveAttribute('src', path);
await expect
.poll(() => image.evaluate((node) => (node as HTMLImageElement).naturalWidth))
.toBeGreaterThan(0);
}
await page.locator('a[href="/?link=photos"]').click();
await page.getByRole('link', { name: 'Photos', exact: true }).click();
const details = page
.getByRole('region', { name: 'Photos', exact: true })
.locator('img[src="/public/photo.svg"]');
+4 -2
View File
@@ -172,10 +172,12 @@ test('blocked client boot keeps controls disabled and native grid navigation usa
await page.goto(server.url);
await expect(page.getByRole('searchbox', { name: 'Search', exact: true })).toBeDisabled();
await expect(page.getByRole('button', { name: /theme/ })).toBeDisabled();
await page.locator('a[href="/?link=beta"]').click();
const beta = page.getByRole('link', { name: 'Beta service', exact: true });
await expect(beta).toHaveJSProperty('href', `${server.url}/?link=beta`);
await beta.click();
await expect(page).toHaveURL(`${server.url}/?link=beta`);
await expect(page.getByRole('dialog')).toHaveCount(0);
await page.locator('a[href="/?link=alpha"]').click();
await page.getByRole('link', { name: 'Alpha service', exact: true }).click();
await expect(page).toHaveURL(`${server.url}/?link=alpha`);
await expect(page.locator('.tile')).toHaveCount(2);
});
+7 -9
View File
@@ -49,15 +49,13 @@ test('groups, bookmark titles and plain details work without JavaScript', async
['router', 'Router'],
['notes', 'Household notes']
]) {
const link = page.locator(`a[href="/?link=${id}"]`);
await expect(link).toContainText(label!);
await expect(link).toHaveAttribute('data-sveltekit-preload-data', 'off');
const link = page.getByRole('link', { name: label!, exact: true });
await expect(link).toHaveJSProperty('href', `${server.url}/?link=${id}`);
await expect(link).toHaveAttribute('data-sveltekit-preload-data', 'false');
}
await expect(page.locator('a[href="/?link=photos"] img')).toHaveAttribute(
'src',
'/bookmarks/photos/logo'
);
await page.locator('a[href="/?link=photos"]').click();
const photos = page.getByRole('link', { name: 'Photos', exact: true });
await expect(photos.locator('img')).toHaveAttribute('src', '/bookmarks/photos/logo');
await photos.click();
await expect(page).toHaveURL(`${server.url}/?link=photos`);
await expect(page.getByRole('heading', { name: 'Photos', exact: true })).toBeVisible();
const details = page.getByRole('region', { name: 'Photos', exact: true });
@@ -80,7 +78,7 @@ test('groups, bookmark titles and plain details work without JavaScript', async
await expect(tags).toHaveCount(1);
await expect(tags.getByRole('listitem')).toHaveText(['login', 'internal', 'admin']);
await expect(tags.getByRole('listitem').last()).toHaveAccessibleName('admin only');
await expect(page.locator('a[href="/?link=photos"]')).toBeVisible();
await expect(page.getByRole('link', { name: 'Photos', exact: true })).toBeVisible();
} finally {
await context.close();
expect(await server.stop()).toEqual({ code: 0, signal: null });
+12
View File
@@ -47,6 +47,18 @@ test('search finds a bookmark, opens its details and clears back to browsing', a
await expect(results.getByRole('link')).toHaveCount(1);
await expect(results.getByRole('link')).toContainText('Family photos');
await expect(page).toHaveURL(`${server.url}/?q=photos`);
// Async shallow routing must not rewrite an in-place edit or move its caret to the end.
await search.press('End');
await search.press('ArrowLeft');
await search.press('x');
await expect(search).toHaveValue('photoxs');
await expect(page).toHaveURL(`${server.url}/?q=photoxs`);
await expect(search).toBeFocused();
await expect(search).toHaveJSProperty('selectionStart', 6);
await expect(search).toHaveJSProperty('selectionEnd', 6);
await search.press('Backspace');
await expect(search).toHaveValue('photos');
await expect(page).toHaveURL(`${server.url}/?q=photos`);
await page.reload();
await expect(search).toHaveValue('photos');
await expect(results.getByRole('link')).toHaveCount(1);
+1 -1
View File
@@ -122,7 +122,7 @@ test('without JavaScript a broken configured header leaves title and native navi
await expect(page.locator('header img')).toHaveAttribute('src', sourceA);
await expectBroken(page.locator('header img'));
await expect(page.locator('header h1')).toHaveText(title);
await page.locator('a[href="/?link=book"]').click();
await page.locator('a[href$="?link=book"]').click();
await expect(page).toHaveURL(`${app.url}/?link=book`);
await expect(page.locator('header .description')).toHaveText(siteDescription);
} finally {
+2 -2
View File
@@ -71,8 +71,8 @@ export async function startServer(options: ServerOptions) {
const script = `
import { once } from 'node:events';
const { server } = await import(${JSON.stringify(serverModule)});
if (!server.server.listening) await once(server.server, 'listening');
process.send({ ready: server.server.address().port });
if (!server.listening) await once(server, 'listening');
process.send({ ready: server.address().port });
`;
const child = spawn(process.execPath, ['--input-type=module', '--eval', script], {
cwd: temporary,
+3
View File
@@ -40,5 +40,8 @@
"unplugin-icons": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
},
"imports": {
"#lib/*": "./src/lib/*"
}
}
-1
View File
@@ -15,7 +15,6 @@ declare global {
}
interface PageState {
directoryQuery?: string;
directoryDetail?: { id: string; fromGrid: boolean } | null;
}
+11 -6
View File
@@ -1,5 +1,6 @@
import type { Handle } from '@sveltejs/kit/hooks';
import type { RequestEvent } from '@sveltejs/kit';
import { validateConfig } from '@directory/config';
import type { Handle, RequestEvent } from '@sveltejs/kit';
import { themeStyleTag } from './lib/server/appearance.js';
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
import type { Authentication } from './lib/server/auth/index.js';
@@ -14,7 +15,6 @@ const adapterEvents: NodeJS.EventEmitter = process;
const mocks = vi.hoisted(() => ({
building: false,
dev: false,
env: { ORIGIN: 'https://directory.example.com' },
boot: vi.fn<typeof import('./lib/server/boot.js').bootApp>(),
report: vi.fn<typeof import('./lib/server/logger.js').reportStartupFailure>(),
load: vi.fn<typeof import('@directory/config').loadConfigFile>(),
@@ -22,8 +22,7 @@ const mocks = vi.hoisted(() => ({
prepare: vi.fn<typeof import('./lib/server/directory/snapshot.js').prepareSnapshot>()
}));
vi.mock('$env/dynamic/private', () => ({ env: mocks.env }));
vi.mock('$app/environment', () => ({
vi.mock('$app/env', () => ({
get building() {
return mocks.building;
},
@@ -172,7 +171,7 @@ describe('server hooks', () => {
expect(ready.currentSnapshot).not.toHaveBeenCalled();
pending.resolve(ready);
await startup;
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, mocks.env);
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith();
expect((await hooks.handle({ event: event(), resolve })).status).toBe(200);
adapterEvents.emit('sveltekit:shutdown');
expect((await hooks.handle({ event: event(), resolve })).status).toBe(503);
@@ -185,7 +184,7 @@ describe('server hooks', () => {
await hooks.init();
expect(development).toHaveBeenCalledOnce();
expect(production).not.toHaveBeenCalled();
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, mocks.env);
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith();
process.emit('directory:dev-stop');
});
@@ -329,9 +328,13 @@ describe('server hooks', () => {
for (const cookie of ['', 'unrelated-cookie=opaque']) {
for (const [path, routeId, params] of [
['/public/photo.svg', '/(public)/public/[...file]', { file: 'photo.svg' }],
['/public/site.svg', '/(public)/public/[...file]', { file: 'site.svg' }],
['/%70ublic/photo.svg', '/(public)/public/[...file]', { file: 'photo.svg' }],
['/public/unknown.svg', '/(public)/public/[...file]', { file: 'unknown.svg' }],
[
'/bookmarks/photos/logo',
'/(protected)/(endpoints)/bookmarks/[id]/logo',
@@ -423,10 +426,12 @@ describe('server hooks', () => {
const themedHead = await transform({ html: head, done: false });
expect(themedHead).toBe(`\t${themeStyleTag(captured.display.appearance)}\n${head}`);
expect(await transform({ html: ending, done: true })).toBe(ending);
return new Response(opening + themedHead + ending, {
headers: { 'content-type': 'text/html' }
});
});
const response = await hooks.handle({ event: request, resolve });
const html = await response.text();
expect(html.match(/<style>/g)).toHaveLength(1);
+4 -6
View File
@@ -1,7 +1,5 @@
import { building, dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import type { Handle, ServerInit } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { building, dev } from '$app/env';
import { sequence, type Handle, type ServerInit } from '@sveltejs/kit/hooks';
import { themeStyleTag } from './lib/server/appearance.js';
import type { ServerState } from './lib/server/application.js';
import { startDevelopment } from './lib/server/lifecycle/development.js';
@@ -13,8 +11,8 @@ const server: ServerState = { application: undefined };
export const init: ServerInit = async () => {
if (building) return;
try {
if (dev) await startDevelopment(server, env);
else await startProduction(server, env);
if (dev) await startDevelopment(server);
else await startProduction(server);
} catch (error) {
reportStartupFailure(error);
// eslint-disable-next-line preserve-caught-error -- Raw startup errors may contain configuration secrets.
@@ -15,10 +15,7 @@
let retryDelay = checkInterval;
$effect(() => {
// Kit shallow navigation updates page.state, not page.url.
void page.state;
void page.url;
const currentURL = new SvelteURL(window.location.href);
const currentURL = new SvelteURL((page.shallow?.url ?? page.url).href);
currentURL.hash = '';
refreshHref = currentURL.href;
});
@@ -1,5 +1,5 @@
<script lang="ts">
import type { DocumentMetadata } from '$lib/server/document.js';
import type { DocumentMetadata } from '#lib/server/document.js';
let { metadata, faviconPath }: { metadata: DocumentMetadata; faviconPath: string } = $props();
</script>
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import Button from '$lib/components/ui/Button.svelte';
import Button from '#lib/components/ui/Button.svelte';
import IconMoon from 'virtual:icons/ph/moon';
import IconSunDim from 'virtual:icons/ph/sun-dim';
import IconFaders from 'virtual:icons/ph/faders';
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { DisplayLink } from '$lib/display/types.js';
import type { DisplayLink } from '#lib/display/types.js';
import BookmarkLogo from './BookmarkLogo.svelte';
import ButtonLink from '../ui/ButtonLink.svelte';
import Chip from '../ui/Chip.svelte';
@@ -4,7 +4,7 @@
import IconX from 'virtual:icons/ph/x';
import IconCaretLeft from 'virtual:icons/ph/caret-left';
import IconCaretRight from 'virtual:icons/ph/caret-right';
import type { DisplayLink } from '$lib/display/types.js';
import type { DisplayLink } from '#lib/display/types.js';
import BookmarkDetails from './BookmarkDetails.svelte';
import Button from '../ui/Button.svelte';
@@ -24,7 +24,7 @@
openLinksInNewTab: boolean;
hasPrevious: boolean;
hasNext: boolean;
onNavigate: (step: -1 | 1) => void;
onNavigate: (step: -1 | 1) => Promise<void>;
onClose: () => void;
onClosed: () => void;
onFilterSelect: (query: string) => void;
@@ -51,8 +51,13 @@
async function navigate(event: MouseEvent, step: -1 | 1) {
const button = event.currentTarget as HTMLButtonElement;
const hadFocus = button === document.activeElement;
onNavigate(step);
try {
await onNavigate(step);
} catch {
return;
}
await tick();
if (!link || !button.isConnected) return;
if (scrollContent) scrollContent.scrollTop = 0;
if (hadFocus && button.disabled) {
(step === 1 ? previousButton : nextButton)?.focus({ preventScroll: true });
@@ -1,6 +1,6 @@
<script lang="ts">
import Logo from '../Logo.svelte';
import type { DisplayLink } from '$lib/display/types.js';
import type { DisplayLink } from '#lib/display/types.js';
interface Props {
readonly link: DisplayLink;
@@ -1,8 +1,8 @@
<script module lang="ts">
let engineImport: Promise<typeof import('$lib/search/engine.js')> | undefined;
let engineImport: Promise<typeof import('#lib/search/engine.js')> | undefined;
function loadEngine() {
engineImport ??= import('$lib/search/engine.js').catch((error: unknown) => {
engineImport ??= import('#lib/search/engine.js').catch((error: unknown) => {
engineImport = undefined;
throw error;
});
@@ -17,8 +17,8 @@
import Button from '../ui/Button.svelte';
import Input from '../ui/Input.svelte';
import { beforeNavigate, afterNavigate } from '$app/navigation';
import type { DirectoryContent } from '$lib/display/types.js';
import type { SearchResult } from '$lib/search/engine.js';
import type { DirectoryContent } from '#lib/display/types.js';
import type { SearchResult } from '#lib/search/engine.js';
let {
directory,
@@ -41,7 +41,7 @@
let navigating = $state(false);
let activated = $state(false);
let completedQuery: string | undefined;
let searchBookmarks: ReturnType<typeof import('$lib/search/engine.js').createSearch> | undefined;
let searchBookmarks: ReturnType<typeof import('#lib/search/engine.js').createSearch> | undefined;
let indexedSnapshotId: string | undefined;
let searchRequestVersion = 0;
let searchUnavailable = false;
@@ -112,11 +112,16 @@
};
});
beforeNavigate(() => {
beforeNavigate(({ shallow, type }) => {
if (shallow && type === 'goto') return;
navigating = true;
searchRequestVersion++;
});
afterNavigate(() => {
afterNavigate(({ shallow, type }) => {
if (shallow && type === 'goto') return;
navigating = false;
if (query.trim()) activated = true;
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { DirectoryContent, DisplayGroup, DisplayLink } from '$lib/display/types.js';
import type { DirectoryContent, DisplayGroup, DisplayLink } from '#lib/display/types.js';
import { createSearch } from './engine.js';
function link(id: string, fields: Partial<DisplayLink> = {}): DisplayLink {
+1 -1
View File
@@ -1,5 +1,5 @@
import Fuse from 'fuse.js';
import type { DirectoryContent, DisplayLink } from '$lib/display/types.js';
import type { DirectoryContent, DisplayLink } from '#lib/display/types.js';
export interface SearchResult {
readonly link: DisplayLink;
+7 -4
View File
@@ -1,5 +1,6 @@
import type { Handle } from '@sveltejs/kit/hooks';
import type { RequestEvent } from '@sveltejs/kit';
import { beforeEach, expect, it, vi } from 'vitest';
import type { Handle, RequestEvent } from '@sveltejs/kit';
import { protectAuthRequest, requireSignIn, type AccessPolicy } from './access.js';
import { logger } from '../logger.js';
@@ -98,9 +99,11 @@ it('rejects either inconsistent request origin before resolving a route', async
for (const replaceRequest of [false, true]) {
const input = requestFor('/');
const foreign = new URL('https://another.example/');
if (replaceRequest) input.event.request = new Request(foreign);
else input.event.url = foreign;
expect((await protectAuthRequest(input, policy)).status).toBe(403);
const event = {
...input.event,
...(replaceRequest ? { request: new Request(foreign) } : { url: foreign })
};
expect((await protectAuthRequest({ ...input, event }, policy)).status).toBe(403);
expect(input.resolve).not.toHaveBeenCalled();
}
});
+8 -4
View File
@@ -1,4 +1,5 @@
import { isRedirect, type Handle, type RequestEvent } from '@sveltejs/kit';
import { isRedirect, type RequestEvent } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit/hooks';
import { logger } from '../logger.js';
import { isSignInError, localRedirect, signInLocation } from './redirect.js';
import { hasExpectedOrigin } from './request.js';
@@ -64,7 +65,7 @@ export async function requireSignIn(
): Promise<Response> {
event.locals.session = null;
const routeId = event.route.id ?? '';
const publicRoute = routeId === '/(public)' || routeId.startsWith('/(public)/');
const publicRoute = isInGroup(routeId, '/(public)');
if (publicRoute) return resolve(event);
try {
event.locals.session = await event.locals.auth();
@@ -73,8 +74,7 @@ export async function requireSignIn(
return denied(401);
}
if (event.locals.session?.user) return resolve(event);
const endpoint =
routeId === '/(protected)/(endpoints)' || routeId.startsWith('/(protected)/(endpoints)/');
const endpoint = isInGroup(routeId, '/(protected)/(endpoints)');
if (!endpoint && isHtmlNavigation(event)) {
const callbackUrl = localRedirect(event.url.pathname + event.url.search, policy.origin);
return new Response(null, {
@@ -85,6 +85,10 @@ export async function requireSignIn(
return denied(401);
}
function isInGroup(routeId: string, group: string): boolean {
return routeId === group || routeId.startsWith(`${group}/`);
}
function isUnsafeMethod(event: RequestEvent): boolean {
return !['GET', 'HEAD', 'OPTIONS'].includes(event.request.method);
}
+5 -5
View File
@@ -40,7 +40,7 @@ export async function signInAction(
if (!['https:', 'http:'].includes(destination.protocol)) {
throw new Error('Invalid authentication destination.');
}
redirect(303, destination.href);
redirect(303, destination.href, { external: true });
}
redirect(303, callbackUrl);
} catch (failure) {
@@ -86,10 +86,10 @@ async function submitAuth(
);
// The same native cookie helpers used by sveltekitCookies, without bypassing the router.
for (const [name, attributes] of parseSetCookieHeader(response.headers.get('set-cookie') ?? '')) {
event.cookies.set(name, attributes.value, {
...toCookieOptions(attributes),
path: attributes.path || '/'
});
const options = Object.fromEntries(
Object.entries(toCookieOptions(attributes)).filter(([, value]) => value !== undefined)
);
event.cookies.set(name, attributes.value, { ...options, path: attributes.path || '/' });
}
return response;
}
+2 -2
View File
@@ -1,8 +1,8 @@
import type { RequestEvent } from '@sveltejs/kit';
import { randomBytes } from 'node:crypto';
import { betterAuth, type BetterAuthOptions } from 'better-auth/minimal';
import { isAuthPath } from 'better-auth/svelte-kit';
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle, RequestEvent } from '@sveltejs/kit';
import { sequence, type Handle } from '@sveltejs/kit/hooks';
import type { DirectoryAuth } from '@directory/config/types';
import { logger } from '../logger.js';
import { protectAuthRequest, requireSignIn } from './access.js';
+18 -7
View File
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it, onTestFinished, vi } from 'vitest';
import { applySetCookies } from 'better-auth/cookies';
import { PUBLIC_AUTHORITY_HEADER, PUBLIC_PROTOCOL_HEADER } from './runtime-environment.js';
const serverModule = new URL('../../../build/bootstrap/start.js', import.meta.url).href;
const sentinel = 'PRIVATE_CONFIG_CREDENTIAL_LINK';
@@ -38,8 +39,8 @@ async function launchServer(contents: string | undefined, environment: NodeJS.Pr
const script = `
import { once } from 'node:events';
const { server } = await import(${JSON.stringify(serverModule)});
if (!server.server.listening) await once(server.server, 'listening');
process.send(server.server.address().port);
if (!server.listening) await once(server, 'listening');
process.send(server.address().port);
`;
const child = spawn(process.execPath, ['--input-type=module', '--eval', script], {
cwd: temporary,
@@ -202,7 +203,9 @@ it.each([
host: 'foreign.example.com',
'x-forwarded-host': 'foreign.example.com',
'x-forwarded-proto': 'http',
'x-forwarded-port': '80'
'x-forwarded-port': '80',
[PUBLIC_PROTOCOL_HEADER]: 'http',
[PUBLIC_AUTHORITY_HEADER]: 'foreign.example.com:80'
};
const gated = await fetch(address, { redirect: 'manual', headers: transportHeaders });
expect(gated.status).toBe(303);
@@ -231,18 +234,26 @@ it.each([
applySetCookies(headers, response.headers.getSetCookie());
const authenticated = await fetch(address, { redirect: 'manual', headers });
expect(authenticated.status).toBe(200);
expect(await authenticated.text()).not.toContain(sentinel);
const html = await authenticated.text();
expect(html).toContain(`property="og:url" content="${origin}/"`);
expect(html).not.toContain(sentinel);
expect(server.output()).not.toContain(sentinel);
expect(server.output()).not.toContain(server.path);
expect(await server.stop()).toEqual({ code: 0, signal: null });
});
it('keeps application environment references and fallbacks stable across bootstrap and reloads', async () => {
const fallbackTitle = '${DIRECTORY_TITLE} ${MISSING_TITLE:-Fallback}';
const title = 'Configured Fallback';
const fallbackTitle =
'${DIRECTORY_TITLE} ${MISSING_TITLE:-Fallback} ${ORIGIN} ${HOST_HEADER} ${PROTOCOL_HEADER} ${PORT_HEADER}';
const title =
'Configured Fallback http://localhost original-host original-protocol original-port';
const server = await launchServer(source(fallbackTitle), {
...hostileEnvironment,
DIRECTORY_TITLE: 'Configured'
DIRECTORY_TITLE: 'Configured',
ORIGIN: 'http://localhost',
HOST_HEADER: 'original-host',
PROTOCOL_HEADER: 'original-protocol',
PORT_HEADER: 'original-port'
});
const address = await server.ready();
await expectPage(address, title);
+25 -46
View File
@@ -12,7 +12,8 @@ const mocks = vi.hoisted(() => ({
load: vi.fn<typeof import('@directory/config').loadConfigFile>(),
monitor: vi.fn<typeof import('@directory/config').startConfigReloading>(),
auth: vi.fn<typeof import('./auth/index.js').createAuthentication>(),
stop: vi.fn<() => void>()
stop: vi.fn<() => void>(),
runtime: vi.fn<typeof import('./runtime-environment.js').getRuntimeEnvironment>()
}));
vi.mock('@directory/config', async (importOriginal) => ({
...(await importOriginal<typeof import('@directory/config')>()),
@@ -22,11 +23,11 @@ vi.mock('@directory/config', async (importOriginal) => ({
startConfigReloading: mocks.monitor
}));
vi.mock('./auth/index.js', () => ({ createAuthentication: mocks.auth }));
vi.mock('./runtime-environment.js', () => ({ getRuntimeEnvironment: mocks.runtime }));
const path = '/config/directory.yaml';
const sentinel = 'PRIVATE-config-credential';
const origin = 'https://directory.example.com';
const frameworkEnv = { ORIGIN: origin };
const auth = { credentials: { username: 'family', password: sentinel } };
const authentication: Authentication = {
strategy: 'credentials',
@@ -41,7 +42,7 @@ function configuration(title = 'Home', enabled = false): DirectoryConfiguration
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv('ORIGIN', origin);
mocks.runtime.mockReturnValue({ env: process.env, origin });
mocks.path.mockReturnValue(path);
mocks.publicDir.mockReturnValue('/mounted/public');
mocks.load.mockResolvedValue(configuration());
@@ -56,7 +57,7 @@ describe('application boot wiring', () => {
const pending = Promise.withResolvers<DirectoryConfiguration>();
mocks.load.mockReturnValueOnce(pending.promise);
const env = { CONFIG_PATH: path, DIRECTORY_PUBLIC_DIR: '/mounted/public' };
const startup = bootApp(env);
const startup = bootApp({ env, origin });
expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env });
expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith(path, { env });
expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env });
@@ -106,11 +107,11 @@ describe('application boot wiring', () => {
});
it('constructs auth before monitoring from the prepared origin, not legacy auth settings', async () => {
vi.stubEnv('ORIGIN', origin);
vi.stubEnv('ORIGIN', 'https://not-kit.example.com');
vi.stubEnv('AUTH_URL', 'https://not-kit.example.com');
mocks.load.mockResolvedValueOnce(configuration('Home', true));
const env = { ORIGIN: sentinel, AUTH_URL: sentinel };
const app = await bootApp(env, frameworkEnv);
const app = await bootApp({ env, origin });
expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env });
expect(mocks.auth).toHaveBeenCalledExactlyOnceWith({ ...auth, origin });
expect(mocks.auth.mock.invocationCallOrder[0]).toBeLessThan(
@@ -122,51 +123,30 @@ describe('application boot wiring', () => {
describe.each([false, true])('canonical URL consistency (auth enabled: %s)', (enabled) => {
it('boots with the prepared canonical URL regardless of auth state', async () => {
mocks.load.mockResolvedValueOnce(configuration('Home', enabled));
const app = await bootApp({}, frameworkEnv);
const app = await bootApp({ env: {}, origin });
expect(app.currentSnapshot().configuration.site.url).toBe(origin);
expect(app.authentication).toBe(enabled ? authentication : undefined);
});
it.each([
{ name: 'process ORIGIN missing', prepared: undefined, privateEnv: frameworkEnv },
{ name: 'process ORIGIN empty', prepared: '', privateEnv: frameworkEnv },
{
name: 'prepared process origin changed',
prepared: 'https://other.example.com',
privateEnv: frameworkEnv
},
{ name: 'Kit private URLs missing', prepared: origin, privateEnv: {} },
{
name: 'Kit private ORIGIN differs',
prepared: origin,
privateEnv: { ...frameworkEnv, ORIGIN: sentinel }
},
{
name: 'site.url changed after preparation',
prepared: origin,
privateEnv: frameworkEnv,
url: 'https://other.example.com'
}
])(
'fails closed when $name before auth or monitoring',
async ({ prepared, privateEnv, ...change }) => {
vi.stubEnv('ORIGIN', prepared);
const config = configuration('Home', enabled);
if ('url' in change && change.url) config.site.url = change.url;
mocks.load.mockResolvedValueOnce(config);
await expect(bootApp({}, privateEnv)).rejects.toMatchObject({
issue: { code: 'auth_failed' }
});
expect(mocks.auth).not.toHaveBeenCalled();
expect(mocks.monitor).not.toHaveBeenCalled();
}
);
it('rejects a changed site.url before auth or monitoring', async () => {
const config = configuration('Home', enabled);
config.site.url = 'https://other.example.com';
mocks.load.mockResolvedValueOnce(config);
await expect(bootApp({ env: {}, origin })).rejects.toMatchObject({
issue: { code: 'auth_failed' }
});
expect(mocks.auth).not.toHaveBeenCalled();
expect(mocks.monitor).not.toHaveBeenCalled();
});
});
it('uses the process environment when no environments are injected', async () => {
it('uses the captured environment rather than the modified process environment', async () => {
const env = { CONFIG_PATH: path, HOST_HEADER: 'original-host-header' };
mocks.runtime.mockReturnValue({ env, origin });
vi.stubEnv('HOST_HEADER', 'changed-by-adapter-preparation');
await bootApp();
expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env: process.env });
expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env: process.env });
expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env });
expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env });
});
it('retains safe loader diagnostics without starting monitoring', async () => {
@@ -196,12 +176,11 @@ describe('application boot wiring', () => {
);
it('does not start monitoring when authentication initialization fails', async () => {
vi.stubEnv('ORIGIN', origin);
mocks.load.mockResolvedValueOnce(configuration('Home', true));
mocks.auth.mockImplementationOnce(() => {
throw new Error(sentinel);
});
await expect(bootApp({}, frameworkEnv)).rejects.toMatchObject({
await expect(bootApp({ env: {}, origin })).rejects.toMatchObject({
issue: { code: 'auth_failed' }
});
expect(mocks.monitor).not.toHaveBeenCalled();
+5 -5
View File
@@ -5,16 +5,16 @@ import {
resolvePublicDir,
startConfigReloading
} from '@directory/config';
import type { ConfigEnvironment, DirectoryConfiguration } from '@directory/config/types';
import type { DirectoryConfiguration } from '@directory/config/types';
import { createApplication, type Application } from './application.js';
import { DirectoryError } from './directory-error.js';
import { reportConfigError } from './logger.js';
import { getRuntimeEnvironment, type RuntimeEnvironment } from './runtime-environment.js';
export async function bootApp(
env: ConfigEnvironment = process.env,
frameworkEnv: ConfigEnvironment = process.env
runtime: RuntimeEnvironment = getRuntimeEnvironment()
): Promise<Application> {
const options = { env };
const options = { env: runtime.env };
let path: string;
let publicDir: string;
let config: DirectoryConfiguration;
@@ -30,7 +30,7 @@ export async function bootApp(
}
// Reject changes between framework URL preparation and the initial application load.
if (config.site.url !== process.env.ORIGIN || config.site.url !== frameworkEnv.ORIGIN) {
if (config.site.url !== runtime.origin) {
throw new DirectoryError({ code: 'auth_failed' });
}
@@ -48,7 +48,7 @@ describe('development lifecycle', () => {
mocks.boot.mockResolvedValueOnce(ready);
const off = vi.spyOn(process, 'off');
await startDevelopment(server);
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, undefined);
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith();
expect(server.application).toBe(ready);
expect(listeners.mock.calls.filter(([name]) => name === 'sveltekit:shutdown')).toEqual([]);
const shutdown = devStopListener();
@@ -1,4 +1,3 @@
import type { ConfigEnvironment } from '@directory/config/types';
import { bootApp } from '../boot.js';
import type { Application, ServerState } from '../application.js';
@@ -8,17 +7,14 @@ interface DevelopmentAttempt {
stopped: boolean;
}
export async function startDevelopment(
server: ServerState,
env?: ConfigEnvironment
): Promise<void> {
export async function startDevelopment(server: ServerState): Promise<void> {
process.emit('directory:dev-stop');
const attempt: DevelopmentAttempt = { server, application: undefined, stopped: false };
const shutdown = () => stopDevelopment(attempt, shutdown);
process.once('directory:dev-stop', shutdown);
let application: Application;
try {
application = await bootApp(undefined, env);
application = await bootApp();
} catch (error) {
stopDevelopment(attempt, shutdown);
throw error;
@@ -44,7 +44,7 @@ describe('production lifecycle', () => {
const ready = application('Production');
mocks.boot.mockResolvedValueOnce(ready);
await startProduction(server);
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, undefined);
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith();
expect(server.application).toBe(ready);
expect(listeners.mock.calls.filter(([name]) => name === 'directory:dev-stop')).toEqual([]);
const shutdown = listeners.mock.calls.find(([name]) => name === 'sveltekit:shutdown')![1];
@@ -1,9 +1,8 @@
import type { ConfigEnvironment } from '@directory/config/types';
import { bootApp } from '../boot.js';
import type { Application, ServerState } from '../application.js';
export async function startProduction(server: ServerState, env?: ConfigEnvironment): Promise<void> {
const application = await bootApp(undefined, env);
export async function startProduction(server: ServerState): Promise<void> {
const application = await bootApp();
// adapter-node emits shutdown after listening, which waits for Kit init.
process.once('sveltekit:shutdown', () => stopProduction(server, application));
server.application = application;
@@ -1,27 +1,29 @@
import { spawn } from 'node:child_process';
import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { spawn, spawnSync } from 'node:child_process';
import { cp, mkdir, mkdtemp, readdir, rm, symlink, writeFile } from 'node:fs/promises';
import {
createServer as createHttpServer,
type IncomingMessage,
type ServerResponse
} from 'node:http';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DirectoryError } from './directory-error.js';
import { prepareRuntimeEnvironment } from './runtime-environment.js';
import {
installRequestOrigin,
prepareAdapterEnvironment,
prepareRuntimeEnvironment,
PUBLIC_AUTHORITY_HEADER,
PUBLIC_PROTOCOL_HEADER
} from './runtime-environment.js';
const origin = 'https://directory.example.com';
const sentinel = 'PRIVATE-runtime-setting';
let directory: string;
let path: string;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'directory-runtime-'));
path = join(directory, 'directory.yaml');
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
function credentials(extra = ''): string {
return `links: {}
site:
@@ -33,8 +35,36 @@ auth:
${extra}`;
}
describe('framework environment preparation', () => {
it('captures the final Vite CLI port before Kit snapshots the stale framework origin', async () => {
describe('runtime environment preparation', () => {
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'directory-runtime-'));
path = join(directory, 'directory.yaml');
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
it('loads tooling configuration without requiring operator YAML', () => {
const web = fileURLToPath(new URL('../../../', import.meta.url));
const script = `
import { loadConfig } from ${JSON.stringify(import.meta.resolve('@sveltejs/load-config'))};
const result = await loadConfig(${JSON.stringify(web)}, { traverse: false });
if (!result) throw new Error('Svelte configuration not found');
if ('error' in result) throw result.error;
`;
const env: NodeJS.ProcessEnv = { ...process.env, CONFIG_PATH: path };
delete env.VITEST;
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', script], {
cwd: web,
env,
encoding: 'utf8',
timeout: 20_000
});
expect(result.status, result.stdout + result.stderr).toBe(0);
}, 30_000);
it('uses the final Vite CLI port for request URLs despite stale environment settings', async () => {
const web = fileURLToPath(new URL('../../../', import.meta.url));
const vite = join(web, 'node_modules/vite/bin/vite.js');
const host = '127.0.0.1';
@@ -50,12 +80,16 @@ describe('framework environment preparation', () => {
if (address === null || typeof address === 'string') throw new Error('Expected a TCP port');
const port = String(address.port);
// Run the real Vite/Kit lifecycle without writing generated files into the working app.
await mkdir(join(directory, 'src/routes'), { recursive: true });
for (const file of ['src/app.html', 'svelte.config.js', 'tsconfig.json']) {
for (const file of ['src/app.html', 'tsconfig.json']) {
await cp(join(web, file), join(directory, file));
}
await symlink(join(web, 'node_modules'), join(directory, 'node_modules'), 'dir');
// Kit 3 must generate this fixture's own node_modules/$app, not overwrite the app's.
await mkdir(join(directory, 'node_modules'));
for (const name of await readdir(join(web, 'node_modules'))) {
if (name.startsWith('.') || name === '$app') continue;
await symlink(join(web, 'node_modules', name), join(directory, 'node_modules', name), 'dir');
}
await writeFile(join(directory, 'package.json'), '{"type":"module"}');
await writeFile(
join(directory, 'vite.config.mjs'),
@@ -63,18 +97,21 @@ describe('framework environment preparation', () => {
export default (env) => {
const result = config(env);
return { ...result, cacheDir: ${JSON.stringify(join(directory, '.vite'))},
server: { ...result.server, fs: { allow: ${JSON.stringify([directory, web])} } } };
server: { ...result.server, fs: { allow: ${JSON.stringify([directory, join(web, '../..')])} } } };
};`
);
await writeFile(path, 'links: {}');
await writeFile(
join(directory, 'src/hooks.server.ts'),
`export { init, handle } from ${JSON.stringify(join(web, 'src/hooks.server.ts'))};`
);
await writeFile(
join(directory, '.env'),
'PORT=2\nORIGIN=https://stale.example.com\nAUTH_URL=https://stale.example.com\n'
);
await writeFile(
join(directory, 'src/routes/+page.server.js'),
`import { env } from '$env/dynamic/private';
export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
`export const load = ({ url }) => ({ settings: url.origin + '|' + process.env.PORT });`
);
await writeFile(
join(directory, 'src/routes/+page.svelte'),
@@ -88,7 +125,13 @@ export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
[vite, '--host', host, '--configLoader', 'runner', '--port', port],
{
cwd: directory,
env: { ...env, NODE_ENV: 'development', PORT: '1', CONFIG_PATH: path },
env: {
...env,
NODE_ENV: 'development',
PORT: '1',
CONFIG_PATH: path,
DIRECTORY_PUBLIC_DIR: join(directory, 'public')
},
stdio: ['ignore', 'pipe', 'pipe']
}
);
@@ -99,13 +142,16 @@ export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
child.once('error', (error) => (failure = error));
const closed = new Promise<void>((resolve) => child.once('close', () => resolve()));
try {
// Probe HTTP, not Vite's console URL (which changes with CI and ANSI colors).
const html = await vi.waitFor(
async () => {
expect(failure, output).toBeUndefined();
expect(child.exitCode, output).toBeNull();
const response = await fetch(`http://${host}:${port}/`, {
headers: { connection: 'close' },
headers: {
connection: 'close',
[PUBLIC_PROTOCOL_HEADER]: 'https',
[PUBLIC_AUTHORITY_HEADER]: 'foreign.example.com'
},
signal: AbortSignal.timeout(5000)
});
const html = await response.text();
@@ -126,7 +172,7 @@ export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
}
}, 30_000);
it('uses site.url instead of legacy framework settings', async () => {
it('uses YAML site.url without treating legacy framework settings as configuration', async () => {
await writeFile(path, credentials());
const env = {
CONFIG_PATH: path,
@@ -137,8 +183,10 @@ export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
AUTH_SECRET: sentinel
};
const before = { ...env };
await prepareRuntimeEnvironment(env);
expect(env).toEqual({ ...before, ORIGIN: origin });
const runtime = await prepareRuntimeEnvironment(env);
expect(runtime.origin).toBe(origin);
expect(runtime.env).toEqual(before);
expect(env).toEqual(before);
});
it.each([
@@ -146,63 +194,55 @@ export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
{ settings: { NODE_ENV: 'development' }, expected: 'http://localhost:5173' },
{ settings: { NODE_ENV: 'production', PORT: '8080' }, expected: 'http://localhost:8080' }
])(
'sets canonical framework URLs for an open directory: $expected',
'prepares the default origin for an open directory: $expected',
async ({ settings, expected }) => {
await writeFile(path, 'links: {}');
const env = {
CONFIG_PATH: path,
ORIGIN: sentinel,
AUTH_URL: sentinel,
NEXTAUTH_URL: sentinel,
AUTH_REDIRECT_PROXY_URL: sentinel,
...settings
};
await prepareRuntimeEnvironment(env);
expect(env.ORIGIN).toBe(expected);
expect(env.AUTH_URL).toBe(sentinel);
const runtime = await prepareRuntimeEnvironment({ CONFIG_PATH: path, ...settings });
expect(runtime.origin).toBe(expected);
}
);
it('uses an explicit site.url even with auth disabled and an ephemeral listen port', async () => {
await writeFile(path, `site:\n url: '${origin}'\nlinks: {}`);
const env = { CONFIG_PATH: path, PORT: '0', ORIGIN: sentinel, AUTH_URL: sentinel };
await prepareRuntimeEnvironment(env);
expect(env.ORIGIN).toBe(origin);
expect(env.AUTH_URL).toBe(sentinel);
});
it('preserves explicit legacy-named environment interpolation in YAML', async () => {
it('keeps interpolation values captured before transport settings are changed', async () => {
await writeFile(path, credentials().replace(origin, '${ORIGIN}'));
const env = { CONFIG_PATH: path, ORIGIN: origin, AUTH_URL: sentinel };
await prepareRuntimeEnvironment(env);
expect(env.ORIGIN).toBe(origin);
expect(env.AUTH_URL).toBe(sentinel);
const env = {
CONFIG_PATH: path,
PORT: '0',
ORIGIN: origin,
HOST_HEADER: 'original-host',
PROTOCOL_HEADER: 'original-protocol',
PORT_HEADER: 'original-port',
AUTH_SECRET: sentinel
};
const before = { ...env };
const runtime = await prepareRuntimeEnvironment(env);
prepareAdapterEnvironment(env);
expect(runtime.origin).toBe(origin);
expect(runtime.env).toEqual(before);
expect(env.ORIGIN).toBeUndefined();
expect(env).toMatchObject({
HOST_HEADER: PUBLIC_AUTHORITY_HEADER,
PROTOCOL_HEADER: PUBLIC_PROTOCOL_HEADER,
PORT_HEADER: '',
AUTH_SECRET: sentinel
});
});
it.each([{ PORT: '0' }, { SOCKET_PATH: '/tmp/directory.sock' }, { LISTEN_FDS: '1' }])(
'fails safely without site.url for a nonstandard listener: %j',
async (settings) => {
await writeFile(path, 'links: {}');
const env = {
CONFIG_PATH: path,
ORIGIN: sentinel,
AUTH_URL: sentinel,
NEXTAUTH_URL: sentinel,
AUTH_REDIRECT_PROXY_URL: sentinel,
...settings
};
const before = { ...env };
await expect(prepareRuntimeEnvironment(env)).rejects.toMatchObject({
await expect(
prepareRuntimeEnvironment({ CONFIG_PATH: path, ...settings })
).rejects.toMatchObject({
issue: {
code: 'load_failed',
issues: [expect.objectContaining({ code: 'invalid_value', path: '/site/url' })]
}
});
expect(env).toEqual(before);
}
);
it('fails safely before changing the environment for invalid configuration', async () => {
it('redacts invalid configuration without changing the caller environment', async () => {
await writeFile(path, credentials(` secret: '${sentinel}'\n`));
const env = { CONFIG_PATH: path, ORIGIN: sentinel, AUTH_URL: sentinel };
const before = { ...env };
@@ -221,3 +261,38 @@ export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
});
});
});
it.each([
{
origin: 'https://directory.example.com:8443',
protocol: 'https',
authority: 'directory.example.com:8443'
},
{ origin: 'http://[::1]:8080', protocol: 'http', authority: '[::1]:8080' }
])(
'overwrites private headers before the adapter sees $origin, without rewriting browser Origin',
({ origin, protocol, authority }) => {
let received: IncomingMessage['headers'] | undefined;
const server = createHttpServer((request) => {
received = { ...request.headers };
});
installRequestOrigin(server, origin);
const request = {
headers: {
host: 'foreign.example.com',
origin: 'https://foreign.example.com',
'x-forwarded-host': 'foreign.example.com',
[PUBLIC_PROTOCOL_HEADER]: 'https',
[PUBLIC_AUTHORITY_HEADER]: 'foreign.example.com'
}
} as unknown as IncomingMessage;
server.emit('request', request, {} as ServerResponse);
expect(received).toEqual({
host: 'foreign.example.com',
origin: 'https://foreign.example.com',
'x-forwarded-host': 'foreign.example.com',
[PUBLIC_PROTOCOL_HEADER]: protocol,
[PUBLIC_AUTHORITY_HEADER]: authority
});
}
);
+45 -5
View File
@@ -1,12 +1,29 @@
import { ConfigError, loadConfigFile } from '@directory/config';
import type { ConfigEnvironment } from '@directory/config/types';
import type { Server } from 'node:http';
import { DirectoryError } from './directory-error.js';
export const PUBLIC_PROTOCOL_HEADER = 'x-directory-public-protocol';
export const PUBLIC_AUTHORITY_HEADER = 'x-directory-public-authority';
export interface RuntimeEnvironment {
readonly env: ConfigEnvironment;
readonly origin: string;
}
// The standalone bootstrap and Kit's bundled hooks need the same captured values.
const runtimeKey = Symbol.for('directory.runtime-environment');
const runtimeState = globalThis as typeof globalThis & {
[runtimeKey]?: RuntimeEnvironment;
};
export async function prepareRuntimeEnvironment(
env: NodeJS.ProcessEnv = process.env
): Promise<void> {
env: ConfigEnvironment = process.env
): Promise<RuntimeEnvironment> {
const captured = Object.freeze({ ...env });
let origin: string;
try {
const config = await loadConfigFile(undefined, { env });
const config = await loadConfigFile(undefined, { env: captured });
origin = config.site.url;
} catch (error) {
throw new DirectoryError({
@@ -15,6 +32,29 @@ export async function prepareRuntimeEnvironment(
});
}
// adapter-node 5 still captures ORIGIN before Kit starts; authentication uses site.url directly.
env.ORIGIN = origin;
const runtime = Object.freeze({ env: captured, origin });
runtimeState[runtimeKey] = runtime;
return runtime;
}
export function getRuntimeEnvironment(): RuntimeEnvironment {
const runtime = runtimeState[runtimeKey];
if (!runtime) throw new DirectoryError({ code: 'prepare_failed' });
return runtime;
}
export function prepareAdapterEnvironment(env: NodeJS.ProcessEnv = process.env): void {
delete env.ORIGIN;
env.PROTOCOL_HEADER = PUBLIC_PROTOCOL_HEADER;
env.HOST_HEADER = PUBLIC_AUTHORITY_HEADER;
env.PORT_HEADER = '';
}
export function installRequestOrigin(server: Server, origin: string): void {
const url = new URL(origin);
const protocol = url.protocol.slice(0, -1);
server.prependListener('request', (request) => {
request.headers[PUBLIC_PROTOCOL_HEADER] = protocol;
request.headers[PUBLIC_AUTHORITY_HEADER] = url.host;
});
}
@@ -1,7 +1,7 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
export const GET = (({ locals }) =>
json(
Response.json(
{ snapshotId: locals.snapshot.display.snapshotId },
{ headers: { 'cache-control': 'private, no-store' } }
)) satisfies RequestHandler;
+81 -34
View File
@@ -2,24 +2,27 @@
import { onMount } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import IconSignOut from 'virtual:icons/ph/sign-out';
import { beforeNavigate, pushState, replaceState } from '$app/navigation';
import { beforeNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import BookmarkLogo from '$lib/components/bookmarks/BookmarkLogo.svelte';
import BookmarkDetails from '$lib/components/bookmarks/BookmarkDetails.svelte';
import BookmarkDetailsDialog from '$lib/components/bookmarks/BookmarkDetailsDialog.svelte';
import Logo from '$lib/components/Logo.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import BookmarkSearch from '$lib/components/search/BookmarkSearch.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { nextResultIndex } from '$lib/search/result-navigation.js';
import type { DisplayLink } from '$lib/display/types.js';
import type { SearchResult } from '$lib/search/engine.js';
import BookmarkLogo from '#lib/components/bookmarks/BookmarkLogo.svelte';
import BookmarkDetails from '#lib/components/bookmarks/BookmarkDetails.svelte';
import BookmarkDetailsDialog from '#lib/components/bookmarks/BookmarkDetailsDialog.svelte';
import Logo from '#lib/components/Logo.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
import BookmarkSearch from '#lib/components/search/BookmarkSearch.svelte';
import Button from '#lib/components/ui/Button.svelte';
import { nextResultIndex } from '#lib/search/result-navigation.js';
import type { DisplayLink } from '#lib/display/types.js';
import type { SearchResult } from '#lib/search/engine.js';
import type { PageData } from './$types.js';
let { data }: { data: PageData } = $props();
// Kit shallow routing updates page.state, not page.url.
const searchQuery = $derived(page.state.directoryQuery ?? page.url.searchParams.get('q') ?? '');
const currentURL = $derived(page.shallow?.url ?? page.url);
let pendingSearchQuery = $state<string>();
let searchNavigation = 0;
const searchQuery = $derived(pendingSearchQuery ?? currentURL.searchParams.get('q') ?? '');
let searchResults = $state<SearchResult[] | null>(null);
let searchStatus = $state<'idle' | 'loading' | 'ready' | 'failed'>('idle');
let searchInput = $state<HTMLInputElement>();
@@ -39,7 +42,7 @@
const directoryURL = $derived.by(() => {
const parameters = new SvelteURLSearchParams();
if (searchQuery) parameters.set('q', searchQuery);
return parameters.size ? `/?${parameters}` : '/';
return parameters.size ? resolve(`?${parameters}`) : resolve('');
});
const selectedGroup = $derived(
data.directory.groups.find((group) => group.links.some((link) => link.id === selectedId))
@@ -61,18 +64,29 @@
function setSearchQuery(query: string) {
if (query === searchQuery) return;
// Function-bound inputs must update before asynchronous route resolution finishes.
pendingSearchQuery = query;
const navigation = ++searchNavigation;
const parameters = new SvelteURLSearchParams();
if (selectedId !== null) parameters.set('link', selectedId);
if (query) parameters.set('q', query);
const url = parameters.size ? `/?${parameters}` : '/';
// eslint-disable-next-line svelte/no-navigation-without-resolve -- Query URLs stay absolute in this root-only directory.
replaceState(url, { ...page.state, directoryQuery: query });
const url = parameters.size ? resolve(`?${parameters}`) : resolve('');
void goto(url, { shallow: true, replace: true, state: page.state }).then(
() => {
if (navigation === searchNavigation) pendingSearchQuery = undefined;
},
() => {
if (navigation !== searchNavigation) return;
pendingSearchQuery = undefined;
window.location.replace(url);
}
);
}
function detailURL(id: string) {
const parameters = new SvelteURLSearchParams({ link: id });
if (searchQuery) parameters.set('q', searchQuery);
return `/?${parameters}`;
return resolve(`?${parameters}`);
}
function findBookmarkTile(id: string) {
@@ -138,31 +152,51 @@
returnTile = tile;
pendingFilterQuery = undefined;
isClosingDetails = false;
// eslint-disable-next-line svelte/no-navigation-without-resolve -- This absolute href comes from the real tile anchor.
pushState(href, { ...page.state, directoryDetail: { id, fromGrid: true } });
void goto(href, {
shallow: true,
state: { ...page.state, directoryDetail: { id, fromGrid: true } }
}).catch(() => {
window.location.href = href;
});
}
function navigateDetails(step: -1 | 1) {
async function navigateDetails(step: -1 | 1): Promise<void> {
if (isClosingDetails || detailIndex < 0) return;
const id = detailOrder[detailIndex + step];
if (!id) return;
returnTile = findBookmarkTile(id);
// eslint-disable-next-line svelte/no-navigation-without-resolve -- Root-only URLs must stay absolute.
replaceState(detailURL(id), {
...page.state,
directoryDetail: { id, fromGrid: page.state.directoryDetail?.fromGrid ?? false }
return goto(detailURL(id), {
shallow: true,
replace: true,
state: {
...page.state,
directoryDetail: { id, fromGrid: page.state.directoryDetail?.fromGrid ?? false }
}
});
}
function closeDetails() {
async function closeDetails() {
if (isClosingDetails) return;
isClosingDetails = true;
if (page.state.directoryDetail?.fromGrid) {
history.back();
return;
}
// eslint-disable-next-line svelte/no-navigation-without-resolve -- Root-only URLs must stay absolute.
replaceState(directoryURL, { ...page.state, directoryDetail: null });
try {
await goto(directoryURL, {
shallow: true,
replace: true,
state: { ...page.state, directoryDetail: null }
});
} catch {
return;
} finally {
// A rejected or cancelled navigation leaves the current dialog available to retry.
if (selectedLink) {
isClosingDetails = false;
pendingFilterQuery = undefined;
}
}
}
function selectDetailFilter(query: string) {
@@ -196,7 +230,22 @@
directoryHeading?.focus({ preventScroll: true });
}
beforeNavigate(() => {
beforeNavigate((navigation) => {
if (navigation.shallow && navigation.type === 'goto') return;
searchNavigation++;
pendingSearchQuery = undefined;
// Kit may reconstruct this Back traversal after a query followed by a reload.
const returningFromGrid =
navigation.shallow &&
navigation.type === 'popstate' &&
navigation.delta === -1 &&
isClosingDetails &&
page.state.directoryDetail?.fromGrid &&
navigation.to?.route?.id === '/(protected)' &&
navigation.to.url.href === new URL(directoryURL, currentURL.href).href;
if (returningFromGrid) return;
isClosingDetails = false;
pendingFilterQuery = undefined;
returnTile = undefined;
@@ -266,8 +315,8 @@
{:else if selectedLink}
<noscript>
<section class="details" aria-labelledby="link-details-title">
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- Root-only URLs must stay absolute in SSR. -->
<a class="back" href={directoryURL} data-sveltekit-preload-data="off">Back to directory</a>
<a class="back" href={directoryURL} data-sveltekit-preload-data="false">Back to directory</a
>
<BookmarkDetails
link={selectedLink}
groupLabel={selectedGroup?.label}
@@ -278,11 +327,10 @@
{/if}
{#snippet tile(link: DisplayLink, groupLabel: string | undefined, filtered = false)}
<!-- eslint-disable svelte/no-navigation-without-resolve -- Root-only URLs must stay absolute in SSR. -->
<a
class="tile"
href={detailURL(link.id)}
data-sveltekit-preload-data="off"
data-sveltekit-preload-data="false"
onkeydown={filtered ? navigateResults : undefined}
onclick={(event) => openDetails(event, link.id)}
>
@@ -292,7 +340,6 @@
<small class="group-caption">{groupLabel}</small>
{/if}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/snippet}
<div class="search-feedback">
+4 -1
View File
@@ -50,7 +50,10 @@ function eventFor(query = '', withAuth = false) {
>[0];
}
const notFound = expect.objectContaining({ status: 404, body: { message: 'Not found' } });
const notFound = expect.objectContaining({
status: 404,
body: expect.objectContaining({ message: 'Not found' })
});
describe('directory page data', () => {
it('returns exactly the prepared display, selection and signed-in boolean in no-auth mode', () => {
@@ -80,11 +80,14 @@ describe('public favicon alias', () => {
'/favicon?source=%252e%252e%252fsecret.svg',
'/favicon/other.svg?favicon_path=/public/other.svg'
]) {
const request = event('GET');
request.url = new URL(path, 'http://localhost');
request.request = new Request(request.url, {
headers: { 'x-public-dir': '/outside', range: 'bytes=0-1', 'if-none-match': '*' }
});
const url = new URL(path, 'http://localhost');
const request = {
...event('GET'),
url,
request: new Request(url, {
headers: { 'x-public-dir': '/outside', range: 'bytes=0-1', 'if-none-match': '*' }
})
};
expect((await GET(request)).status).toBe(200);
}
expect(readMountedImage).toHaveBeenCalledTimes(3);
@@ -1,4 +1,7 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
export const GET = (() =>
json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } })) satisfies RequestHandler;
Response.json(
{ status: 'ok' },
{ headers: { 'cache-control': 'no-store' } }
)) satisfies RequestHandler;
@@ -166,21 +166,24 @@ describe('public generated Open Graph image', () => {
'/images/og.png?favicon_path=/public/other.svg&og_image_path=/public/other.jpg&primary_light_color=%23fff&primary_dark_color=%23000',
'/images/other.png?source=%252e%252e%252fsecret.svg&title=OTHER'
]) {
const request = event(method);
request.url = new URL(path, 'http://attacker.example');
request.request = new Request(request.url, {
method,
headers: {
'x-public-dir': '/outside',
'x-favicon-path': '/public/other.svg',
'x-title': 'HEADER_TITLE',
'x-primary-light-color': '#fff',
'x-primary-dark-color': '#000',
accept: 'image/jpeg',
range: 'bytes=0-1',
'if-none-match': '*'
}
});
const url = new URL(path, 'http://attacker.example');
const request = {
...event(method),
url,
request: new Request(url, {
method,
headers: {
'x-public-dir': '/outside',
'x-favicon-path': '/public/other.svg',
'x-title': 'HEADER_TITLE',
'x-primary-light-color': '#fff',
'x-primary-dark-color': '#000',
accept: 'image/jpeg',
range: 'bytes=0-1',
'if-none-match': '*'
}
})
};
await expectPNG(await (method === 'HEAD' ? HEAD : GET)(request), method);
}
expect(vi.mocked(readMountedImage).mock.calls).toEqual(
@@ -80,11 +80,14 @@ describe('public selected-logo alias', () => {
'/logo?source=%252e%252e%252fsecret.svg',
'/logo/other.svg?logo_path=/public/other.svg'
]) {
const request = event('GET');
request.url = new URL(path, 'http://localhost');
request.request = new Request(request.url, {
headers: { 'x-public-dir': '/outside', range: 'bytes=0-1', 'if-none-match': '*' }
});
const url = new URL(path, 'http://localhost');
const request = {
...event('GET'),
url,
request: new Request(url, {
headers: { 'x-public-dir': '/outside', range: 'bytes=0-1', 'if-none-match': '*' }
})
};
expect((await GET(request)).status).toBe(200);
}
expect(readMountedImage).toHaveBeenCalledTimes(3);
@@ -57,14 +57,17 @@ describe('public mounted image route', () => {
);
it('ignores conditional and range headers', async () => {
const request = event('icon.svg');
request.request = new Request(request.request, {
headers: {
'if-none-match': '*',
'if-modified-since': new Date().toUTCString(),
range: 'bytes=0-2'
}
});
const captured = event('icon.svg');
const request = {
...captured,
request: new Request(captured.request, {
headers: {
'if-none-match': '*',
'if-modified-since': new Date().toUTCString(),
range: 'bytes=0-2'
}
})
};
const response = await GET(request);
expect(response.status).toBe(200);
expect(await response.text()).toBe('<svg/>');
@@ -1,8 +1,7 @@
import { json } from '@sveltejs/kit';
import editorSchema from '@directory/config/schema.json' with { type: 'json' };
export function GET(): Response {
return json(editorSchema, {
return Response.json(editorSchema, {
headers: {
'content-type': 'application/schema+json; charset=utf-8',
'cache-control': 'no-store'
@@ -2,9 +2,9 @@
import { onMount } from 'svelte';
import IconWarningCircle from 'virtual:icons/ph/warning-circle';
import IconSignIn from 'virtual:icons/ph/sign-in';
import Logo from '$lib/components/Logo.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
import Logo from '#lib/components/Logo.svelte';
import Button from '#lib/components/ui/Button.svelte';
import Input from '#lib/components/ui/Input.svelte';
import type { PageData } from './$types.js';
let { data }: { data: PageData } = $props();
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import ButtonLink from '$lib/components/ui/ButtonLink.svelte';
import ButtonLink from '#lib/components/ui/ButtonLink.svelte';
</script>
<svelte:head>
+2 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount, type Snippet } from 'svelte';
import { page } from '$app/state';
import ConfigUpdateNotice from '$lib/components/ConfigUpdateNotice.svelte';
import DocumentHead from '$lib/components/DocumentHead.svelte';
import ConfigUpdateNotice from '#lib/components/ConfigUpdateNotice.svelte';
import DocumentHead from '#lib/components/DocumentHead.svelte';
import type { LayoutData } from './$types';
import '../lib/styles/reset.css';
import '../lib/styles/theme.css';
+10 -6
View File
@@ -1,13 +1,17 @@
import { prepareRuntimeEnvironment } from './lib/server/runtime-environment.js';
import {
installRequestOrigin,
prepareAdapterEnvironment,
prepareRuntimeEnvironment
} from './lib/server/runtime-environment.js';
import { reportStartupFailure } from './lib/server/logger.js';
try {
await prepareRuntimeEnvironment();
} catch (error) {
const runtime = await prepareRuntimeEnvironment().catch((error: unknown) => {
reportStartupFailure(error);
process.exit(1);
}
});
prepareAdapterEnvironment();
// adapter-node captures ORIGIN during import, before its Kit init hook runs.
const entry = '../index.js';
export const { server } = await import(entry);
// Adapter initialization finishes before listening; install before the next request turn.
installRequestOrigin(server, runtime.origin);
-26
View File
@@ -1,26 +0,0 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const connectSources = ['self'];
if (process.env.NODE_ENV === 'development') connectSources.push('ws:', 'wss:');
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
csp: {
// OIDC form redirects cross origins; state-changing requests are checked server-side.
directives: {
'default-src': ['self'],
'script-src': ['self'],
'style-src': ['self', 'unsafe-inline'],
'img-src': ['self'],
'font-src': ['self'],
'connect-src': connectSources,
'object-src': ['none'],
'base-uri': ['self'],
'frame-ancestors': ['none']
}
}
}
};
+3 -29
View File
@@ -1,37 +1,11 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"extends": "$app/tsconfig",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"moduleResolution": "bundler",
"types": ["node", "unplugin-icons/types/svelte"]
"types": ["$app/types", "node", "unplugin-icons/types/svelte"]
},
// Keep the generated Kit includes in sync with .svelte-kit/tsconfig.json.
"include": [
".svelte-kit/ambient.d.ts",
".svelte-kit/env.d.ts",
".svelte-kit/non-ambient.d.ts",
".svelte-kit/types/**/$types.d.ts",
"vite.config.js",
"vite.config.ts",
"src/**/*.js",
"src/**/*.ts",
"src/**/*.svelte",
"test/**/*.js",
"test/**/*.ts",
"test/**/*.svelte",
"tests/**/*.js",
"tests/**/*.ts",
"tests/**/*.svelte",
"playwright.config.ts",
"e2e/**/*.ts"
]
"include": ["src", "vite.config.ts", "vitest.config.ts", "playwright.config.ts", "e2e/**/*.ts"]
}
+52 -14
View File
@@ -1,14 +1,36 @@
import adapter from '@sveltejs/adapter-node';
import type { Adapter } from '@sveltejs/kit';
import { getRequest } from '@sveltejs/kit/node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig, loadEnv } from 'vite';
import { prepareRuntimeEnvironment } from './src/lib/server/runtime-environment.js';
import Icons from 'unplugin-icons/vite';
const connectSources: ('self' | 'ws:' | 'wss:')[] = ['self'];
if (process.env.NODE_ENV === 'development') connectSources.push('ws:', 'wss:');
function createAdapter(getOrigin: () => string | undefined): Adapter {
const native = adapter();
const nativeVite = native.vite;
return {
...native,
vite: (context) => {
const settings = typeof nativeVite === 'function' ? nativeVite(context) : nativeVite;
return {
...settings,
getRequest: (options) => getRequest({ ...options, base: getOrigin() ?? options.base })
};
}
};
}
export default defineConfig(({ command, mode, isPreview }) => {
const development = command === 'serve' && !isPreview;
if (development) {
Object.assign(process.env, loadEnv(mode, process.cwd(), ''));
}
const port = Number(process.env.PORT ?? 5173);
const environment = development ? loadEnv(mode, process.cwd(), '') : undefined;
const port = Number(environment?.PORT ?? process.env.PORT ?? 5173);
let origin: string | undefined;
return {
server: { port, strictPort: true },
optimizeDeps: { include: ['fuse.js'] },
@@ -16,18 +38,34 @@ export default defineConfig(({ command, mode, isPreview }) => {
{
name: 'directory-dev-environment',
enforce: 'pre',
config: {
order: 'pre',
async handler(config) {
if (!development) return;
// CLI overrides are merged before config hooks. Kit snapshots env in its own
// config hook, so configResolved would leave $env/dynamic/private stale.
process.env.PORT = String(config.server?.port ?? 5173);
await prepareRuntimeEnvironment();
}
async configureServer(server) {
if (!development) return;
Object.assign(process.env, environment);
// Config discovery also uses command=serve. Wait for a real dev server,
// after CLI overrides, before reading operator YAML.
process.env.PORT = String(server.config.server.port ?? 5173);
origin = (await prepareRuntimeEnvironment()).origin;
}
},
sveltekit(),
sveltekit({
preprocess: vitePreprocess(),
adapter: createAdapter(() => origin),
csp: {
// OIDC form redirects cross origins; state-changing requests are checked server-side.
directives: {
'default-src': ['self'],
'script-src': ['self'],
'style-src': ['self', 'unsafe-inline'],
'img-src': ['self'],
'font-src': ['self'],
'connect-src': connectSources,
'object-src': ['none'],
'base-uri': ['self'],
'frame-ancestors': ['none']
}
}
}),
Icons({ compiler: 'svelte' }),
// Kit's SSR loader does not run import.meta.hot.dispose.
{
+9 -1
View File
@@ -4,7 +4,15 @@ import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './apps/web/svelte.config.js';
import { loadConfig } from '@sveltejs/load-config';
import { fileURLToPath } from 'node:url';
const loadedConfig = await loadConfig(fileURLToPath(new URL('./apps/web', import.meta.url)), {
traverse: false
});
if (!loadedConfig) throw new Error('Svelte configuration not found');
if ('error' in loadedConfig) throw loadedConfig.error;
const svelteConfig = loadedConfig.config;
export default defineConfig(
{
+1
View File
@@ -28,6 +28,7 @@
"devDependencies": {
"@changesets/cli": "catalog:",
"@eslint/js": "catalog:",
"@sveltejs/load-config": "catalog:",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-svelte": "catalog:",
+99 -507
View File
File diff suppressed because it is too large. Load diff
+5 -4
View File
@@ -10,8 +10,9 @@ catalog:
'@eslint/js': 10.0.1
'@iconify-json/ph': 1.2.2
'@playwright/test': 1.61.1
'@sveltejs/adapter-node': 5.5.4
'@sveltejs/kit': 2.70.1
'@sveltejs/adapter-node': 6.0.1
'@sveltejs/kit': 3.1.1
'@sveltejs/load-config': 0.2.2
'@sveltejs/vite-plugin-svelte': 7.1.2
'@types/node': 24.12.4
'@vitest/coverage-v8': 4.1.7
@@ -24,8 +25,8 @@ catalog:
globals: 17.6.0
prettier: 3.8.3
prettier-plugin-svelte: 3.5.2
svelte: 5.56.0
svelte-check: 4.4.8
svelte: 5.57.3
svelte-check: 4.7.7
typescript: 6.0.3
typescript-eslint: 8.60.0
unplugin-icons: 23.0.1