diff --git a/.changeset/kit3-api-cleanup.md b/.changeset/kit3-api-cleanup.md new file mode 100644 index 00000000..f2b71a87 --- /dev/null +++ b/.changeset/kit3-api-cleanup.md @@ -0,0 +1,6 @@ +--- +"@luzzle/web": patch +--- + +Finish the SvelteKit 3 API migration with native JSON responses and refresh APIs, +retaining response statuses/cache headers, local navigation and editor draft guards. diff --git a/.changeset/kit3-navigation-reactivity-lint.md b/.changeset/kit3-navigation-reactivity-lint.md new file mode 100644 index 00000000..71a240ef --- /dev/null +++ b/.changeset/kit3-navigation-reactivity-lint.md @@ -0,0 +1,7 @@ +--- +"@luzzle/web": patch +--- + +Adopt SvelteKit 3-aware navigation and reactivity lint rules, resolve internal +navigation without changing resource URLs, and validate asset-editor return +destinations with the existing local-redirect policy. diff --git a/.changeset/kit3-toolchain-preparation.md b/.changeset/kit3-toolchain-preparation.md new file mode 100644 index 00000000..f36256b3 --- /dev/null +++ b/.changeset/kit3-toolchain-preparation.md @@ -0,0 +1,11 @@ +--- +"@luzzle/web": patch +"@luzzle/web.worker": patch +"@luzzle/web.templates": patch +"@luzzle/web.jobs": patch +--- + +Prepare the shared build and test toolchain for SvelteKit 3 while retaining Kit 2 +and the current application behavior. Align the web, worker and template Svelte +compiler/runtime at 5.57.3, preserving native runtime OG compilation and rendering. +Keep workflow-spec declarations portable with the updated TypeScript compiler. diff --git a/.changeset/sveltekit-three-integration.md b/.changeset/sveltekit-three-integration.md new file mode 100644 index 00000000..4e22c156 --- /dev/null +++ b/.changeset/sveltekit-three-integration.md @@ -0,0 +1,7 @@ +--- +"@luzzle/web": patch +--- + +Integrate SvelteKit 3 with the stock web build, native fixtures and runtime YAML +configuration. Preserve configured public origins before native CSRF checks, +stateless authentication, built-in errors and draft-preserving editor behavior. diff --git a/apps/cli/package.json b/apps/cli/package.json index 0718fafe..5157b6ea 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -34,7 +34,7 @@ "@types/node": "catalog:", "@types/yargs": "17.0.33", "@vitest/coverage-v8": "catalog:", - "@vitest/ui": "3.2.4", + "@vitest/ui": "catalog:", "eslint": "catalog:", "eslint-config-prettier": "catalog:", "eslint-plugin-prettier": "5.2.1", diff --git a/apps/cli/tsconfig.build.json b/apps/cli/tsconfig.build.json index 2a72bb9e..0095a880 100644 --- a/apps/cli/tsconfig.build.json +++ b/apps/cli/tsconfig.build.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "exclude": [ - "**/*.test.ts" + "**/*.test.ts", + "**/*.mock.ts" ] } diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 110995ce..f411c64e 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "verbatimModuleSyntax": true, + "types": ["node"], "target": "ES2022", "lib": ["dom", "dom.iterable", "ES2022"], "allowJs": true, diff --git a/apps/cli/vite.config.ts b/apps/cli/vite.config.ts index 812e145b..1f572c38 100644 --- a/apps/cli/vite.config.ts +++ b/apps/cli/vite.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { exclude: ['generated/**', 'dist/**', 'build/**', 'node_modules/**'], coverage: { + include: ['src/**/*.{js,ts}'], thresholds: { lines: 92, branches: 92, @@ -23,7 +24,6 @@ export default defineConfig({ '**/*.d.ts', '**/virtual:*', '**/__x00__*', - '**/\x00*', 'cypress/**', 'test?(s)/**', 'test?(-*).?(c|m)[jt]s?(x)', diff --git a/apps/core/package.json b/apps/core/package.json index 1d8a4c4f..cc64bc1c 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -37,7 +37,7 @@ "@types/mdast": "4.0.4", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", - "@vitest/ui": "3.2.4", + "@vitest/ui": "catalog:", "eslint": "catalog:", "eslint-config-prettier": "catalog:", "globals": "catalog:", diff --git a/apps/core/tsconfig.json b/apps/core/tsconfig.json index b3835a43..0953f207 100644 --- a/apps/core/tsconfig.json +++ b/apps/core/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "verbatimModuleSyntax": true, + "types": ["node"], "outDir": "dist", "target": "ES2022", "lib": [ diff --git a/apps/core/vite.config.ts b/apps/core/vite.config.ts index db8f3ef3..853728c5 100644 --- a/apps/core/vite.config.ts +++ b/apps/core/vite.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { exclude: ['generated/**', 'dist/**', 'build/**', 'node_modules/**'], coverage: { + include: ['src/**/*.{js,ts}'], thresholds: { lines: 92, branches: 92, diff --git a/apps/lsp/tsconfig.build.json b/apps/lsp/tsconfig.build.json index 2a72bb9e..759da7f2 100644 --- a/apps/lsp/tsconfig.build.json +++ b/apps/lsp/tsconfig.build.json @@ -1,5 +1,6 @@ { "extends": "./tsconfig.json", + "compilerOptions": { "tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo" }, "exclude": [ "**/*.test.ts" ] diff --git a/apps/lsp/tsconfig.json b/apps/lsp/tsconfig.json index 293e7229..633f2fb1 100644 --- a/apps/lsp/tsconfig.json +++ b/apps/lsp/tsconfig.json @@ -1,8 +1,9 @@ { "compilerOptions": { "verbatimModuleSyntax": true, - "baseUrl": ".", + "types": ["node"], "outDir": "dist", + "rootDir": "./src", "target": "ES2022", "lib": ["ES2022"], "skipLibCheck": true, diff --git a/apps/lsp/vite.config.ts b/apps/lsp/vite.config.ts index f4f5c71d..cda1ac30 100644 --- a/apps/lsp/vite.config.ts +++ b/apps/lsp/vite.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { exclude: ['dist/**', 'node_modules/**'], coverage: { + include: ['src/**/*.{js,ts}'], thresholds: { lines: 80, branches: 80, diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 6be210bb..321cd827 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -5,7 +5,7 @@ node_modules # Output .output .vercel -/.svelte-kit +.svelte-kit/ /build /package-lock.json /src/lib/ui/styles/theme.build.css diff --git a/apps/web/docker-compose.dev.yml b/apps/web/docker-compose.dev.yml index 30db624d..e69bed83 100644 --- a/apps/web/docker-compose.dev.yml +++ b/apps/web/docker-compose.dev.yml @@ -30,7 +30,6 @@ services: - ./static:/m/apps/web/static - ./scripts:/m/apps/web/scripts - ./tsconfig.json:/m/apps/web/tsconfig.json - - ./svelte.config.js:/m/apps/web/svelte.config.js - ./vite.config.ts:/m/apps/web/vite.config.ts networks: - luzzle diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index f88f8791..e976525c 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -3,6 +3,12 @@ import ts from 'typescript-eslint' import svelte from 'eslint-plugin-svelte' import prettier from 'eslint-config-prettier' import globals from 'globals' +import { loadConfig } from '@sveltejs/load-config' + +const loadedConfig = await loadConfig(import.meta.dirname, { traverse: false }) +if (!loadedConfig) throw new Error('Svelte configuration not found') +if ('error' in loadedConfig) throw loadedConfig.error +const svelteConfig = loadedConfig.config /** @type {import('eslint').Linter.Config[]} */ export default [ @@ -33,7 +39,8 @@ export default [ files: ['**/*.svelte'], languageOptions: { parserOptions: { - parser: ts.parser + parser: ts.parser, + svelteConfig } } }, diff --git a/apps/web/package.json b/apps/web/package.json index 4fbfabf1..da27795c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,10 +45,11 @@ "@luzzle/core": "workspace:*", "@luzzle/web.config": "workspace:*", "@luzzle/web.jobs": "workspace:*", - "@sveltejs/adapter-node": "5.3.2", - "@sveltejs/enhanced-img": "0.8.1", - "@sveltejs/kit": "2.43.0", - "@sveltejs/vite-plugin-svelte": "6.2.0", + "@sveltejs/adapter-node": "6.0.1", + "@sveltejs/enhanced-img": "1.0.0", + "@sveltejs/kit": "3.1.1", + "@sveltejs/load-config": "0.2.2", + "@sveltejs/vite-plugin-svelte": "7.1.2", "@types/eslint": "catalog:", "@types/mime-types": "3.0.1", "@types/node": "catalog:", @@ -56,7 +57,7 @@ "codemirror": "6.0.2", "eslint": "catalog:", "eslint-config-prettier": "catalog:", - "eslint-plugin-svelte": "3.9.3", + "eslint-plugin-svelte": "3.23.1", "feedsmith": "2.1.0", "globals": "catalog:", "mime-types": "3.0.2", @@ -68,8 +69,8 @@ "remark-lint-no-duplicate-headings": "4.0.1", "remark-preset-lint-consistent": "6.0.1", "remark-preset-lint-recommended": "7.0.1", - "svelte": "5.55.9", - "svelte-check": "4.3.2", + "svelte": "5.57.3", + "svelte-check": "4.7.7", "typescript": "catalog:", "typescript-eslint": "catalog:", "unplugin-icons": "22.3.0", @@ -90,5 +91,8 @@ "kysely": "0.28.17", "micromark": "4.0.2", "openworkflow": "0.11.0" + }, + "imports": { + "#lib/*": "./src/lib/*" } } diff --git a/apps/web/scripts/config-mounts.test.ts b/apps/web/scripts/config-mounts.test.ts index e742e20c..2ee299e1 100644 --- a/apps/web/scripts/config-mounts.test.ts +++ b/apps/web/scripts/config-mounts.test.ts @@ -18,6 +18,13 @@ test.each([ expect(volumes.some((volume) => volume.startsWith('./demo/config.yaml:'))).toBe(false) }) +test('development mounts the Vite configuration without the retired Svelte config', async () => { + const compose = parse(await readFile(path.join(web, 'docker-compose.dev.yml'), 'utf8')) + const volumes: string[] = compose.services['luzzle-dev-web'].volumes + expect(volumes).toContain('./vite.config.ts:/m/apps/web/vite.config.ts') + expect(volumes.some((volume) => volume.startsWith('./svelte.config.js:'))).toBe(false) +}) + test('the demo configuration is a regular file in its mounted directory', async () => { const canonical = path.join(web, 'demo/config/config.yaml') expect((await lstat(canonical)).isFile()).toBe(true) diff --git a/apps/web/scripts/manager.js b/apps/web/scripts/manager.js index 302c0370..03fd5490 100644 --- a/apps/web/scripts/manager.js +++ b/apps/web/scripts/manager.js @@ -2,11 +2,11 @@ import { loadConfig } from '@luzzle/web.templates/config' import { spawn } from 'node:child_process' import { writeFile } from 'node:fs/promises' import path from 'node:path' +import { fileURLToPath } from 'node:url' import { createServerEnvironment } from './runtime-environment.js' const PATHS = { CONFIG: path.resolve('config.yaml'), - LOCAL_BUILD: path.resolve('build'), LOG: path.resolve('data/build.log') } @@ -90,7 +90,7 @@ class ExplorerManager { const command = this.isDev ? 'pnpm' : 'node' const args = this.isDev ? ['run', 'dev', '--', '--host'] - : [path.join(PATHS.LOCAL_BUILD, 'index.js')] + : [fileURLToPath(new URL('./start-server.js', import.meta.url))] this.serverProcess = spawn(command, args, spawnOptions) diff --git a/apps/web/scripts/manager.test.ts b/apps/web/scripts/manager.test.ts index e87bde6d..6d5a59a8 100644 --- a/apps/web/scripts/manager.test.ts +++ b/apps/web/scripts/manager.test.ts @@ -16,6 +16,13 @@ import { promisify } from 'node:util' const exec = promisify(execFile) const manager = fileURLToPath(new URL('./manager.js', import.meta.url)) +const launcher = fileURLToPath(new URL('./start-server.js', import.meta.url)) +const absoluteEnvironment = { + HOST: '127.0.0.1', + PROTOCOL_HEADER: 'x-luzzle-public-protocol', + HOST_HEADER: 'x-luzzle-public-authority', + PORT_HEADER: '' +} const cache = fileURLToPath(new URL('../node_modules/.cache/', import.meta.url)) let directory: string @@ -37,7 +44,17 @@ afterEach(async () => { function run(dev = true, environment: NodeJS.ProcessEnv = {}) { const inherited = { ...process.env } - for (const key of ['ORIGIN', 'BETTER_AUTH_URL', 'AUTH_URL', 'NEXTAUTH_URL']) delete inherited[key] + for (const key of [ + 'HOST', + 'ORIGIN', + 'PROTOCOL_HEADER', + 'HOST_HEADER', + 'PORT_HEADER', + 'BETTER_AUTH_URL', + 'AUTH_URL', + 'NEXTAUTH_URL' + ]) + delete inherited[key] return exec(process.execPath, [manager], { cwd: directory, env: { @@ -73,16 +90,20 @@ test('development startup loads config from the mounted working directory withou expect(stdout).not.toContain('Initiating production build sequence') }) -test('production startup uses build and log files in the same working directory', async () => { +test('production delegates to the absolute real launcher with build and log files in the working directory', async () => { await configure() await writeFile( path.join(directory, 'build/index.js'), - 'console.log("manager-server " + JSON.stringify({cwd:process.cwd(),host:process.env.HOST}))' + `import { createServer } from 'node:http' +export const server = createServer() +console.log("manager-server " + JSON.stringify({ + cwd:process.cwd(),host:process.env.HOST,launcher:process.argv[1] +}))` ) const { stdout } = await run(false) expect(stdout).toContain('manager-command run build') expect(stdout).toContain( - `manager-server ${JSON.stringify({ cwd: directory, host: '127.0.0.1' })}` + `manager-server ${JSON.stringify({ cwd: directory, host: '127.0.0.1', launcher })}` ) const log = await readFile(path.join(directory, 'data/build.log'), 'utf8') expect(log).toContain('Config App URL: https://manager.example/garden') @@ -98,7 +119,9 @@ test('fresh production startups follow the compatibility link after atomic confi await symlink('config/config.yaml', filename) await writeFile( path.join(directory, 'build/index.js'), - 'console.log("manager-server " + JSON.stringify({cwd:process.cwd(),host:process.env.HOST}))' + `import { createServer } from 'node:http' +export const server = createServer() +console.log("manager-server " + JSON.stringify({cwd:process.cwd(),host:process.env.HOST}))` ) // Each run is a fresh manager process, not a live-server config reload. @@ -132,21 +155,23 @@ test('startup still fails when the working-directory config is missing', async ( async function captureServerEnvironment() { await writeFile( path.join(directory, 'build/index.js'), - 'console.log("manager-environment " + JSON.stringify({host:process.env.HOST,origin:process.env.ORIGIN}))' + `import { createServer } from 'node:http' +// Keep the native export unbound so this environment snapshot exits naturally. +export const server = createServer() +console.log("manager-environment " + JSON.stringify({ + HOST:process.env.HOST,ORIGIN:process.env.ORIGIN, + PROTOCOL_HEADER:process.env.PROTOCOL_HEADER,HOST_HEADER:process.env.HOST_HEADER, + PORT_HEADER:process.env.PORT_HEADER +}))` ) } -test('production derives the canonical Kit origin from configured url.app', async () => { +test('production selects internal protocol and authority headers from configured url.app', async () => { await configure('https://Manager.EXAMPLE:443/garden/', true) await captureServerEnvironment() const { stdout } = await run(false) expect(stdout).toContain('manager-command run build') - expect(stdout).toContain( - `manager-environment ${JSON.stringify({ - host: '127.0.0.1', - origin: 'https://manager.example' - })}` - ) + expect(stdout).toContain(`manager-environment ${JSON.stringify(absoluteEnvironment)}`) }) test('production uses YAML even when ambient URL variables disagree', async () => { @@ -154,40 +179,52 @@ test('production uses YAML even when ambient URL variables disagree', async () = await captureServerEnvironment() const { stdout } = await run(false, { ORIGIN: 'not-a-url', + PROTOCOL_HEADER: 'x-forwarded-proto', + HOST_HEADER: 'x-forwarded-host', + PORT_HEADER: 'x-forwarded-port', BETTER_AUTH_URL: 'https://unexpected.example', AUTH_URL: '', NEXTAUTH_URL: '' }) - expect(stdout).toContain( - 'manager-environment {"host":"127.0.0.1","origin":"https://manager.example"}' - ) + expect(stdout).toContain(`manager-environment ${JSON.stringify(absoluteEnvironment)}`) }) -test.each(['', '/garden'])('production keeps app URL %s request-derived', async (app) => { - await configure(app, false) - await captureServerEnvironment() - const { stdout } = await run(false) - expect(stdout).toContain('manager-environment {"host":"127.0.0.1"}') -}) +test.each(['', '/garden'])( + 'production keeps app URL %s request-derived and clears ambient ORIGIN', + async (app) => { + await configure(app, false) + await captureServerEnvironment() + const { stdout } = await run(false, { + ORIGIN: 'https://unexpected.example', + PROTOCOL_HEADER: 'x-forwarded-proto' + }) + expect(stdout).toContain( + `manager-environment ${JSON.stringify({ + HOST: '127.0.0.1', + PROTOCOL_HEADER: 'x-forwarded-proto' + })}` + ) + } +) -test('production derives Kit origin even with authentication disabled', async () => { +test('production selects configured header settings even with authentication disabled', async () => { await configure() await captureServerEnvironment() const { stdout } = await run(false) - expect(stdout).toContain( - 'manager-environment {"host":"127.0.0.1","origin":"https://manager.example"}' - ) + expect(stdout).toContain(`manager-environment ${JSON.stringify(absoluteEnvironment)}`) }) -test('development derives the same internal Kit origin from YAML', async () => { +test('development selects the same internal headers from YAML and clears ambient ORIGIN', async () => { await configure(undefined, true) await writeFile( path.join(directory, 'bin/pnpm'), - '#!/bin/sh\nprintf "manager-environment HOST=%s ORIGIN=%s\\n" "$HOST" "${ORIGIN-unset}"\n', + '#!/bin/sh\nprintf "manager-environment HOST=%s ORIGIN=%s PROTOCOL_HEADER=%s HOST_HEADER=%s PORT_HEADER=%s\\n" "$HOST" "${ORIGIN-unset}" "$PROTOCOL_HEADER" "$HOST_HEADER" "${PORT_HEADER-unset}"\n', { mode: 0o755 } ) const { stdout } = await run(true, { ORIGIN: 'https://unexpected.example' }) - expect(stdout).toContain('manager-environment HOST=127.0.0.1 ORIGIN=https://manager.example') + expect(stdout).toContain( + 'manager-environment HOST=127.0.0.1 ORIGIN=unset PROTOCOL_HEADER=x-luzzle-public-protocol HOST_HEADER=x-luzzle-public-authority PORT_HEADER=\n' + ) expect(stdout).not.toContain('Initiating production build sequence') }) @@ -206,7 +243,13 @@ test('production rejects unsafe configured URLs before logging values, building test('authenticated production rejects relative app URLs instead of using an ambient origin', async () => { await configure('/garden', true) - await expect(run(false, { ORIGIN: 'https://unexpected.example' })).rejects.toMatchObject({ + await expect( + run(false, { + ORIGIN: 'https://unexpected.example', + BETTER_AUTH_URL: 'https://unexpected.example', + AUTH_URL: 'https://legacy.example' + }) + ).rejects.toMatchObject({ code: 1, stdout: 'FATAL ERROR: Authentication requires an absolute url.app\n' }) diff --git a/apps/web/scripts/runtime-environment.js b/apps/web/scripts/runtime-environment.js index 00c9eeb2..8f3ef35a 100644 --- a/apps/web/scripts/runtime-environment.js +++ b/apps/web/scripts/runtime-environment.js @@ -1,3 +1,6 @@ +const protocolHeader = 'x-luzzle-public-protocol' +const authorityHeader = 'x-luzzle-public-authority' + /** @param {string} value */ function parseAppUrl(value) { const invalid = () => new Error('Invalid url.app: expected an HTTP(S) URL without credentials') @@ -16,7 +19,7 @@ function parseAppUrl(value) { } /** @param {string} app */ -function originFromAppUrl(app) { +export function resolveAppOrigin(app) { const schemeCandidate = app.replace(/[\s\p{Cc}]/gu, '') if (!/^[a-z][a-z\d+.-]*:/i.test(schemeCandidate)) return undefined return parseAppUrl(app).origin @@ -24,11 +27,25 @@ function originFromAppUrl(app) { /** @param {string} app */ export function resolveAuthUrl(app) { - const origin = originFromAppUrl(app) + const origin = resolveAppOrigin(app) if (origin) return origin throw new Error('Authentication requires an absolute url.app') } +/** + * @param {import('node:http').Server} server + * @param {string | undefined} origin + */ +export function installRequestOrigin(server, origin) { + if (!origin) return + const url = new URL(origin) + const protocol = url.protocol.slice(0, -1) + server.prependListener('request', (request) => { + request.headers[protocolHeader] = protocol + request.headers[authorityHeader] = url.host + }) +} + /** * @param {Pick} config * @param {NodeJS.ProcessEnv} environment @@ -37,10 +54,13 @@ export function resolveAuthUrl(app) { export function createServerEnvironment(config, environment = process.env) { /** @type {NodeJS.ProcessEnv} */ const env = { ...environment, HOST: config.network?.public?.host || '0.0.0.0' } - const origin = originFromAppUrl(config.url.app) + const origin = resolveAppOrigin(config.url.app) if (config.auth && !origin) throw new Error('Authentication requires an absolute url.app') - - if (origin) env.ORIGIN = origin - else delete env.ORIGIN + delete env.ORIGIN + if (origin) { + env.PROTOCOL_HEADER = protocolHeader + env.HOST_HEADER = authorityHeader + env.PORT_HEADER = '' + } return env } diff --git a/apps/web/scripts/runtime-environment.test.ts b/apps/web/scripts/runtime-environment.test.ts index f11f8339..1b5c5f25 100644 --- a/apps/web/scripts/runtime-environment.test.ts +++ b/apps/web/scripts/runtime-environment.test.ts @@ -1,6 +1,13 @@ import { afterEach, expect, test, vi } from 'vitest' import type { TemplateConfig } from '@luzzle/web.templates/config' -import { createServerEnvironment, resolveAuthUrl } from './runtime-environment.js' +import { createServer, IncomingMessage, ServerResponse } from 'node:http' +import { Socket } from 'node:net' +import { + createServerEnvironment, + installRequestOrigin, + resolveAppOrigin, + resolveAuthUrl +} from './runtime-environment.js' type RuntimeConfig = Pick const auth: NonNullable = { @@ -38,12 +45,29 @@ test('returns a fresh environment without mutating config or supplied environmen expect(result).toEqual({ HOST: '127.0.0.1', UNRELATED: 'keep', - ORIGIN: 'https://configured.example' + PROTOCOL_HEADER: 'x-luzzle-public-protocol', + HOST_HEADER: 'x-luzzle-public-authority', + PORT_HEADER: '' }) expect(environment).toEqual({ HOST: 'old-host', UNRELATED: 'keep' }) expect(configured.url.app).toBe('https://configured.example/garden/?page=2#top') }) +test('absolute YAML replaces ambient header selection without putting the authority in the environment', () => { + const result = createServerEnvironment(config('https://configured.example:8443/garden'), { + ORIGIN: 'https://unexpected.example', + PROTOCOL_HEADER: 'x-forwarded-proto', + HOST_HEADER: 'x-forwarded-host', + PORT_HEADER: 'x-forwarded-port' + }) + expect(result).toEqual({ + HOST: '127.0.0.1', + PROTOCOL_HEADER: 'x-luzzle-public-protocol', + HOST_HEADER: 'x-luzzle-public-authority', + PORT_HEADER: '' + }) +}) + test('falls back to the existing public bind host when the configured host is empty', () => { const configured = config() configured.network.public.host = '' @@ -56,9 +80,15 @@ test.each([ ['https://example.com:8443/garden/', 'https://example.com:8443'], ['http://localhost:8080', 'http://localhost:8080'], ['https://[::1]:443/', 'https://[::1]'] -])('Kit and auth derive the same canonical origin from %s', (app, expected) => { - expect(createServerEnvironment(config(app, true), {}).ORIGIN).toBe(expected) +])('request boundary and auth derive the same canonical origin from %s', (app, expected) => { + expect(resolveAppOrigin(app)).toBe(expected) expect(resolveAuthUrl(app)).toBe(expected) + expect(createServerEnvironment(config(app, true), {})).toEqual({ + HOST: '127.0.0.1', + PROTOCOL_HEADER: 'x-luzzle-public-protocol', + HOST_HEADER: 'x-luzzle-public-authority', + PORT_HEADER: '' + }) }) test('ambient URL variables cannot replace the configured public URL', () => { @@ -67,7 +97,12 @@ test('ambient URL variables cannot replace the configured public URL', () => { vi.stubEnv('AUTH_URL', 'https://legacy.example') vi.stubEnv('NEXTAUTH_URL', '') const before = { ...process.env } - expect(createServerEnvironment(config(undefined, true)).ORIGIN).toBe('https://configured.example') + expect(createServerEnvironment(config(undefined, true))).toMatchObject({ + PROTOCOL_HEADER: 'x-luzzle-public-protocol', + HOST_HEADER: 'x-luzzle-public-authority', + PORT_HEADER: '' + }) + expect(createServerEnvironment(config(undefined, true))).not.toHaveProperty('ORIGIN') expect(resolveAuthUrl('https://configured.example/garden')).toBe('https://configured.example') expect(process.env).toEqual(before) }) @@ -75,8 +110,19 @@ test('ambient URL variables cannot replace the configured public URL', () => { test.each(['', '/', '/garden/', 'garden', './garden', '../garden', '//example.com/garden'])( 'public-only relative app URL %s leaves Kit request-derived, ignoring ambient ORIGIN', (app) => { - const result = createServerEnvironment(config(app), { ORIGIN: 'https://unexpected.example' }) - expect(result).not.toHaveProperty('ORIGIN') + const result = createServerEnvironment(config(app), { + ORIGIN: 'https://unexpected.example', + PROTOCOL_HEADER: 'x-forwarded-proto', + HOST_HEADER: 'x-forwarded-host', + PORT_HEADER: 'x-forwarded-port' + }) + expect(result).toEqual({ + HOST: '127.0.0.1', + PROTOCOL_HEADER: 'x-forwarded-proto', + HOST_HEADER: 'x-forwarded-host', + PORT_HEADER: 'x-forwarded-port' + }) + expect(resolveAppOrigin(app)).toBeUndefined() expect(() => resolveAuthUrl(app)).toThrow('Authentication requires an absolute url.app') expect(() => createServerEnvironment(config(app, true), {})).toThrow( 'Authentication requires an absolute url.app' @@ -84,6 +130,67 @@ test.each(['', '/', '/garden/', 'garden', './garden', '../garden', '//example.co } ) +test('relative public mode does not introduce header settings when none were supplied', () => { + expect( + createServerEnvironment(config('/garden'), { ORIGIN: 'https://unexpected.example' }) + ).toEqual({ + HOST: '127.0.0.1' + }) +}) + +test('origin listener precedes the native listener on the first and later requests, replacing only internal headers', () => { + const received: Record[] = [] + const nativeListener = vi.fn((request: IncomingMessage) => { + received.push({ method: request.method, url: request.url, headers: { ...request.headers } }) + }) + const server = createServer(nativeListener) + installRequestOrigin(server, resolveAppOrigin('https://Configured.EXAMPLE:8443/garden')) + expect(server.listeners('request')[1]).toBe(nativeListener) + + for (const origin of ['https://configured.example:8443', 'https://browser.example']) { + const request = new IncomingMessage(new Socket()) + request.method = 'POST' + request.url = '/garden/action?next=%2Fprivate&tag=a&tag=b' + request.headers = { + host: 'internal.example:3000', + origin, + cookie: 'session=unchanged', + 'content-type': 'application/x-www-form-urlencoded', + 'x-forwarded-proto': 'http', + 'x-forwarded-host': 'spoofed.example:9999', + 'x-forwarded-port': '9999', + 'x-luzzle-public-protocol': 'http', + 'x-luzzle-public-authority': 'spoofed.example:9999' + } + const original = { ...request.headers } + server.emit('request', request, new ServerResponse(request)) + expect(received.at(-1)).toEqual({ + method: 'POST', + url: '/garden/action?next=%2Fprivate&tag=a&tag=b', + headers: { + ...original, + 'x-luzzle-public-protocol': 'https', + 'x-luzzle-public-authority': 'configured.example:8443' + } + }) + request.destroy() + } + expect(nativeListener).toHaveBeenCalledTimes(2) +}) + +test('relative public origin leaves the native request listener and headers untouched', () => { + const nativeListener = vi.fn() + const server = createServer(nativeListener) + installRequestOrigin(server, resolveAppOrigin('/garden')) + expect(server.listeners('request')).toEqual([nativeListener]) + const request = new IncomingMessage(new Socket()) + request.headers = { host: 'native.example', 'x-forwarded-proto': 'https' } + server.emit('request', request, new ServerResponse(request)) + expect(request.headers).toEqual({ host: 'native.example', 'x-forwarded-proto': 'https' }) + expect(nativeListener).toHaveBeenCalledOnce() + request.destroy() +}) + test.each([ 'ftp://example.com/private-token', 'ws://example.com', diff --git a/apps/web/scripts/start-server.js b/apps/web/scripts/start-server.js index f1f4c105..38313c1f 100644 --- a/apps/web/scripts/start-server.js +++ b/apps/web/scripts/start-server.js @@ -1,19 +1,31 @@ import { loadConfig } from '@luzzle/web.templates/config' import path from 'node:path' import { pathToFileURL } from 'node:url' -import { createServerEnvironment } from './runtime-environment.js' +import { + createServerEnvironment, + installRequestOrigin, + resolveAppOrigin +} from './runtime-environment.js' /** @param {NodeJS.ProcessEnv} environment */ function applyServerEnvironment(environment) { - for (const key of ['HOST', 'ORIGIN']) { + for (const key of ['HOST', 'ORIGIN', 'PROTOCOL_HEADER', 'HOST_HEADER', 'PORT_HEADER']) { const value = environment[key] if (value === undefined) delete process.env[key] else process.env[key] = value } } -const config = loadConfig(path.resolve('config.yaml')) -const environment = createServerEnvironment(config) -applyServerEnvironment(environment) +/** @param {string} entry */ +export async function startServer(entry = path.resolve('build/index.js')) { + const config = loadConfig(path.resolve('config.yaml')) + const origin = resolveAppOrigin(config.url.app) + applyServerEnvironment(createServerEnvironment(config)) + const { server } = await import(pathToFileURL(entry).href) + installRequestOrigin(server, origin) + return server +} -await import(pathToFileURL(path.resolve('build/index.js')).href) +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + await startServer() +} diff --git a/apps/web/scripts/start-server.test.ts b/apps/web/scripts/start-server.test.ts index c834454f..402d1c2d 100644 --- a/apps/web/scripts/start-server.test.ts +++ b/apps/web/scripts/start-server.test.ts @@ -2,12 +2,18 @@ import { afterEach, beforeEach, expect, test } from 'vitest' import { execFile } from 'node:child_process' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import path from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { promisify } from 'node:util' const exec = promisify(execFile) const entry = fileURLToPath(new URL('./start-server.js', import.meta.url)) const cache = fileURLToPath(new URL('../node_modules/.cache/', import.meta.url)) +const absoluteEnvironment = { + HOST: '127.0.0.1', + PROTOCOL_HEADER: 'x-luzzle-public-protocol', + HOST_HEADER: 'x-luzzle-public-authority', + PORT_HEADER: '' +} let directory: string beforeEach(async () => { @@ -16,10 +22,17 @@ beforeEach(async () => { directory = await mkdtemp(path.join(cache, 'server-startup #-')) await mkdir(path.join(directory, 'build')) // Snapshot at module evaluation: the entry must apply the environment before import. + // Export the native Node server, unbound so this snapshot fixture exits naturally. // Never print the entire environment or any auth secrets in the child fixture. await writeFile( path.join(directory, 'build/index.js'), - 'console.log(JSON.stringify({HOST:process.env.HOST,ORIGIN:process.env.ORIGIN}))' + `import { createServer } from 'node:http' +export const server = createServer() +console.log(JSON.stringify({ + HOST:process.env.HOST,ORIGIN:process.env.ORIGIN, + PROTOCOL_HEADER:process.env.PROTOCOL_HEADER,HOST_HEADER:process.env.HOST_HEADER, + PORT_HEADER:process.env.PORT_HEADER +}))` ) }) @@ -27,11 +40,20 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) -function run(environment: NodeJS.ProcessEnv = {}) { +function run(environment: NodeJS.ProcessEnv = {}, args = [entry]) { const inherited = { ...process.env } - for (const key of ['HOST', 'ORIGIN', 'BETTER_AUTH_URL', 'AUTH_URL', 'NEXTAUTH_URL']) + for (const key of [ + 'HOST', + 'ORIGIN', + 'PROTOCOL_HEADER', + 'HOST_HEADER', + 'PORT_HEADER', + 'BETTER_AUTH_URL', + 'AUTH_URL', + 'NEXTAUTH_URL' + ]) delete inherited[key] - return exec(process.execPath, [entry], { + return exec(process.execPath, args, { cwd: directory, env: { ...inherited, ...environment }, timeout: 10000 @@ -56,56 +78,161 @@ async function configure( await writeFile(path.join(directory, 'config.yaml'), JSON.stringify(config)) } -test('loads working-directory config and applies the canonical environment before importing the server', async () => { +test('loads working-directory config and applies internal header settings before importing the server', async () => { await configure() const { stdout } = await run({ HOST: 'ignored-host' }) - expect(JSON.parse(stdout)).toEqual({ - HOST: '127.0.0.1', - ORIGIN: 'https://startup.example' - }) + expect(JSON.parse(stdout)).toEqual(absoluteEnvironment) }) -test('configured url.app supplies internal ORIGIN despite ambient URL settings', async () => { +test('configured url.app selects internal headers and clears ORIGIN despite ambient URL settings', async () => { await configure() const { stdout } = await run({ ORIGIN: 'not-a-url', + PROTOCOL_HEADER: 'x-forwarded-proto', + HOST_HEADER: 'x-forwarded-host', + PORT_HEADER: 'x-forwarded-port', BETTER_AUTH_URL: 'https://unexpected.example', AUTH_URL: 'https://legacy.example', NEXTAUTH_URL: '' }) - expect(JSON.parse(stdout)).toEqual({ HOST: '127.0.0.1', ORIGIN: 'https://startup.example' }) + expect(JSON.parse(stdout)).toEqual(absoluteEnvironment) }) test.each(['', '/garden', './garden', '//relative.example/garden'])( 'leaves app URL %s request-derived before importing the server', async (app) => { await configure(app, false) - const { stdout } = await run({ ORIGIN: 'https://unexpected.example' }) - expect(JSON.parse(stdout)).toEqual({ HOST: '127.0.0.1' }) + const { stdout } = await run({ + ORIGIN: 'https://unexpected.example', + PROTOCOL_HEADER: 'x-forwarded-proto' + }) + expect(JSON.parse(stdout)).toEqual({ + HOST: '127.0.0.1', + PROTOCOL_HEADER: 'x-forwarded-proto' + }) } ) -test('does not add an auth URL when authentication is disabled', async () => { +test('uses configured header settings even when authentication is disabled', async () => { await configure(undefined, false) const { stdout } = await run() - expect(JSON.parse(stdout)).toEqual({ - HOST: '127.0.0.1', - ORIGIN: 'https://startup.example' - }) + expect(JSON.parse(stdout)).toEqual(absoluteEnvironment) }) test('uses the existing HOST fallback for an empty configured bind host', async () => { await configure(undefined, true, '') const { stdout } = await run() - expect(JSON.parse(stdout)).toEqual({ - HOST: '0.0.0.0', - ORIGIN: 'https://startup.example' - }) + expect(JSON.parse(stdout)).toEqual({ ...absoluteEnvironment, HOST: '0.0.0.0' }) }) +test('import alone exports startServer without loading config or starting the default entry', async () => { + // No config exists; the default entry would print a different snapshot if imported. + const { stdout, stderr } = await run({}, [ + '--input-type=module', + '--eval', + `const { startServer } = await import(${JSON.stringify(pathToFileURL(entry).href)}) +console.log(typeof startServer)` + ]) + expect(stdout).toBe('function\n') + expect(stderr).toBe('') +}) + +test.each(['https://Public.EXAMPLE:8443/garden', 'http://Other.EXAMPLE:8080/base'])( + 'startServer returns the native server and normalizes its first HTTP request using %s', + async (app) => { + await configure(app) + const compiledEntry = path.join(directory, 'build/probe #.js') + // This is a native HTTP transport probe, not proof of Kit pre-handle CSRF. + // Like the adapter entry, it installs its request listener and starts listening on import. + await writeFile( + compiledEntry, + `import { createServer } from 'node:http' +export const server = createServer(async (request, response) => { + const protocol = request.headers[process.env.PROTOCOL_HEADER] + const authority = request.headers[process.env.HOST_HEADER] + const url = new URL(request.url, protocol + '://' + authority).href + const headers = { ...request.headers } + let body = '' + for await (const chunk of request) body += chunk + response.setHeader('content-type', 'application/json') + response.end(JSON.stringify({ url, method: request.method, headers, body })) +}) +server.listen(0, '127.0.0.1')` + ) + const { stdout } = await run({ ORIGIN: 'https://ambient.example' }, [ + '--input-type=module', + '--eval', + `import { request } from 'node:http' +import { once } from 'node:events' +const { startServer } = await import(${JSON.stringify(pathToFileURL(entry).href)}) +const server = await startServer(${JSON.stringify(compiledEntry)}) +const compiled = await import(${JSON.stringify(pathToFileURL(compiledEntry).href)}) +if (server !== compiled.server) throw new Error('Native server was replaced') +try { + if (!server.listening) await once(server, 'listening') + const results = [] + for (const origin of ['https://browser.example', 'null']) { + results.push(await new Promise((resolve, reject) => { + const outgoing = request({ + hostname: '127.0.0.1', port: server.address().port, agent: false, + method: 'POST', path: '/submit?next=%2Fprivate&tag=a&tag=b', + headers: { + host: 'internal.example:3000', origin, cookie: 'session=unchanged', + 'content-type': 'application/x-www-form-urlencoded', + 'x-forwarded-proto': 'http', 'x-forwarded-host': 'spoofed.example:9999', + 'x-forwarded-port': '9999', 'x-luzzle-public-protocol': 'http', + 'x-luzzle-public-authority': 'spoofed.example:9999' + } + }, async (response) => { + try { + let body = '' + for await (const chunk of response) body += chunk + resolve(JSON.parse(body)) + } catch (error) { reject(error) } + }) + outgoing.on('error', reject) + outgoing.end('title=one%26two&tag=a&tag=b') + })) + } + console.log(JSON.stringify(results)) +} finally { + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) +}` + ]) + const canonical = new URL(app) + const results = JSON.parse(stdout) + expect(results).toHaveLength(2) + for (const [index, origin] of ['https://browser.example', 'null'].entries()) { + expect(results[index]).toEqual({ + url: `${canonical.origin}/submit?next=%2Fprivate&tag=a&tag=b`, + method: 'POST', + headers: expect.objectContaining({ + host: 'internal.example:3000', + origin, + cookie: 'session=unchanged', + 'content-type': 'application/x-www-form-urlencoded', + 'x-forwarded-proto': 'http', + 'x-forwarded-host': 'spoofed.example:9999', + 'x-forwarded-port': '9999', + 'x-luzzle-public-protocol': canonical.protocol.slice(0, -1), + 'x-luzzle-public-authority': canonical.host + }), + body: 'title=one%26two&tag=a&tag=b' + }) + } + }, + 15000 +) + test('authentication requires an absolute configured app URL before server import', async () => { await configure('/garden') - await expect(run({ ORIGIN: 'https://unexpected.example' })).rejects.toMatchObject({ + await expect( + run({ + ORIGIN: 'https://unexpected.example', + BETTER_AUTH_URL: 'https://unexpected.example', + AUTH_URL: 'https://legacy.example' + }) + ).rejects.toMatchObject({ code: 1, stdout: '', stderr: expect.stringContaining('Authentication requires an absolute url.app') diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index d2ea8b3d..da577a33 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -1,5 +1,5 @@ -import { type AppConfigPublic } from '$lib/server/config' -import type { LuzzleSession as Session } from '$lib/server/auth' +import { type AppConfigPublic } from '#lib/server/config.js' +import type { LuzzleSession as Session } from '#lib/server/auth/index.js' // See https://kit.svelte.dev/docs/types#app // for information about these interfaces diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts index 673245bd..e317ee81 100644 --- a/apps/web/src/hooks.server.test.ts +++ b/apps/web/src/hooks.server.test.ts @@ -1,7 +1,7 @@ +import type { Handle } from '@sveltejs/kit/hooks' import { afterEach, beforeEach, expect, test, vi } from 'vitest' -import type { Handle } from '@sveltejs/kit' import type { Config } from '@luzzle/web.config' -import { credentialsAuth, oidcAuth } from '$lib/server/config.fixture' +import { credentialsAuth, oidcAuth } from '#lib/server/config.fixture.js' const fixture = vi.hoisted(() => ({ building: false, @@ -15,18 +15,18 @@ const fixture = vi.hoisted(() => ({ createAuth: vi.fn(() => ({ id: 'native-auth' })), createAuthHandle: vi.fn(() => (({ event, resolve }) => resolve(event)) satisfies Handle) })) -vi.mock('$app/environment', () => ({ +vi.mock('$app/env', () => ({ get building() { return fixture.building } })) -vi.mock('$lib/server/config', () => ({ config: fixture.config })) -vi.mock('$lib/server/configured-styles', () => ({ +vi.mock('#lib/server/config.js', () => ({ config: fixture.config })) +vi.mock('#lib/server/configured-styles.js', () => ({ prepareConfiguredStyles: fixture.prepareConfiguredStyles, renderConfiguredStyles: ({ event, resolve }: Parameters[0]) => resolve(event) })) -vi.mock('$lib/server/database', () => ({})) -vi.mock('$lib/server/asset-csp', () => ({ +vi.mock('#lib/server/database/index.js', () => ({})) +vi.mock('#lib/server/asset-csp.js', () => ({ createAssetCspHandle: () => ({ event, resolve }: Parameters[0]) => @@ -36,10 +36,11 @@ vi.mock('$lib/server/asset-csp', () => ({ vi.mock('@sveltejs/kit/hooks', () => ({ sequence: (...handles: Handle[]) => handles[handles.length - 1] })) -vi.mock('$lib/server/auth', () => ({ +vi.mock('#lib/server/auth/index.js', () => ({ createAuth: fixture.createAuth, createAuthHandle: fixture.createAuthHandle })) + vi.mock('../scripts/runtime-environment.js', () => ({ resolveAuthUrl: () => fixture.config.url.app })) @@ -49,6 +50,7 @@ beforeEach(() => { fixture.config.auth = credentialsAuth vi.clearAllMocks() }) + afterEach(() => vi.resetModules()) function event( @@ -85,12 +87,12 @@ function event( } test('awaits configured CSS once when the server hooks load, not per request', async () => { - const { handle } = await import('./hooks.server') + const { handle } = await import('./hooks.server.js') expect(fixture.prepareConfiguredStyles).toHaveBeenCalledExactlyOnceWith(fixture.config) for (let request = 0; request < 2; request++) { await handle({ event: event(null, '/', { routeId: '/(root)' }), - resolve: vi.fn().mockResolvedValue(new Response('public')) + resolve: vi.fn[0]['resolve']>().mockResolvedValue(new Response('public')) }) } expect(fixture.prepareConfiguredStyles).toHaveBeenCalledTimes(1) @@ -98,7 +100,7 @@ test('awaits configured CSS once when the server hooks load, not per request', a test('does not prepare configured CSS during framework building', async () => { fixture.building = true - await import('./hooks.server') + await import('./hooks.server.js') expect(fixture.prepareConfiguredStyles).not.toHaveBeenCalled() expect(fixture.createAuth).not.toHaveBeenCalled() expect(fixture.createAuthHandle).toHaveBeenCalledExactlyOnceWith(undefined, true) @@ -106,19 +108,22 @@ test('does not prepare configured CSS during framework building', async () => { test('failed configured CSS preparation rejects server hook loading', async () => { const failure = new Error('Configured CSS preparation failed') + fixture.prepareConfiguredStyles.mockRejectedValueOnce(failure) - await expect(import('./hooks.server')).rejects.toBe(failure) + await expect(import('./hooks.server.js')).rejects.toBe(failure) }) test.each(['/admin', '/admin/pieces', '/api/admin', '/api/admin/generate', '/admin/lsp'])( 'the protected route %s requires a session before resolving its handler', async (pathname) => { - const { handle } = await import('./hooks.server') - const resolve = vi.fn() + const { handle } = await import('./hooks.server.js') + const resolve = vi.fn[0]['resolve']>(async () => new Response('resolved')) + await expect(handle({ event: event(null, pathname), resolve })).rejects.toMatchObject({ status: 302, location: `/signin?${new URLSearchParams({ redirectTo: pathname })}` }) + expect(resolve).not.toHaveBeenCalled() } ) @@ -127,9 +132,11 @@ test.each(['/admin', '/admin/lsp', '/api/admin/generate'])( '%s remains inaccessible without configured auth, even with a session', async (pathname) => { fixture.config.auth = undefined - const { handle } = await import('./hooks.server') - const resolve = vi.fn() + + const { handle } = await import('./hooks.server.js') + const resolve = vi.fn[0]['resolve']>(async () => new Response('resolved')) const request = event({ user: { name: 'test' } }, pathname) + await expect(handle({ event: request, resolve })).rejects.toMatchObject({ status: 302, location: '/' @@ -142,12 +149,14 @@ test.each(['/admin', '/admin/lsp', '/api/admin/generate'])( test('public-only requests resolve without constructing native auth or reading a session', async () => { fixture.config.auth = undefined - const { handle } = await import('./hooks.server') + const { handle } = await import('./hooks.server.js') const request = event(null, '/', { routeId: '/(root)' }) const response = new Response('public', { headers: { 'Cache-Control': 'public, max-age=31536000, immutable' } }) - const resolve = vi.fn().mockResolvedValue(response) + + const resolve = vi.fn[0]['resolve']>().mockResolvedValue(response) + await expect(handle({ event: request, resolve })).resolves.toBe(response) expect(request.locals.auth).not.toHaveBeenCalled() expect(request.setHeaders).not.toHaveBeenCalled() @@ -158,21 +167,26 @@ test('public-only requests resolve without constructing native auth or reading a test.each([false, true])( 'signin preserves an encoded path and its query as one return parameter (data=%s)', async (isDataRequest) => { - const { handle } = await import('./hooks.server') + const { handle } = await import('./hooks.server.js') const pathname = '/garden/%61dmin/piece/books/%E6%BA%90%20%23%3F%25.md/published' const search = '?tag=a%26b&filter=a+b&redirectTo=https%3A%2F%2Fexternal.example' - const resolve = vi.fn() + const resolve = vi.fn[0]['resolve']>(async () => new Response('resolved')) + const request = event(null, pathname, { routeId: '/(auth)/admin/piece/[...path]/published', isDataRequest, search, headers: { accept: isDataRequest ? 'application/json' : 'text/html' } }) + const failure = await Promise.resolve(handle({ event: request, resolve })).catch( (error) => error ) + expect(failure).toMatchObject({ status: 302, location: expect.any(String) }) + const signin = new URL(failure.location, request.url.origin) + expect(signin.pathname).toBe('/signin') expect([...signin.searchParams]).toEqual([['redirectTo', pathname + search]]) expect(resolve).not.toHaveBeenCalled() @@ -182,23 +196,27 @@ test.each([false, true])( test.each(['/admin', '/admin/preview/job/asset/private.png', '/api/admin/generate'])( '%s sets the framework cache policy before resolving protected content', async (pathname) => { - const { handle } = await import('./hooks.server') + const { handle } = await import('./hooks.server.js') const request = event({ user: { name: 'test' } }, pathname) - const resolve = vi.fn(async () => { + + const resolve = vi.fn[0]['resolve']>(async () => { expect(request.setHeaders).toHaveBeenCalledExactlyOnceWith({ 'Cache-Control': 'private, no-store' }) + return new Response('private') }) + await handle({ event: request, resolve }) expect(resolve).toHaveBeenCalledOnce() } ) test('an authenticated generation request reaches its handler', async () => { - const { handle } = await import('./hooks.server') + const { handle } = await import('./hooks.server.js') const response = new Response('resolved') - const resolve = vi.fn().mockResolvedValue(response) + const resolve = vi.fn[0]['resolve']>().mockResolvedValue(response) + await expect(handle({ event: event({ user: { name: 'test' } }), resolve })).resolves.toBe( response ) @@ -229,8 +247,8 @@ const encodedRequests = [ test.each(encodedRequests)( '$method $pathname (data=$isDataRequest) requires a session by matched route identity', async ({ pathname, ...options }) => { - const { handle } = await import('./hooks.server') - const resolve = vi.fn() + const { handle } = await import('./hooks.server.js') + const resolve = vi.fn[0]['resolve']>(async () => new Response('resolved')) const request = event(null, pathname, options) if (options.method === 'GET') { await expect(handle({ event: request, resolve })).rejects.toMatchObject({ @@ -243,6 +261,7 @@ test.each(encodedRequests)( expect(response.headers.has('location')).toBe(false) expect(await response.json()).toEqual({ message: 'Unauthorized' }) } + expect(request.locals.auth).toHaveBeenCalledOnce() expect(resolve).not.toHaveBeenCalled() } @@ -252,9 +271,11 @@ test.each(encodedRequests)( '$pathname remains inaccessible with auth disabled, even with a session (data=$isDataRequest)', async ({ pathname, ...options }) => { fixture.config.auth = undefined - const { handle } = await import('./hooks.server') - const resolve = vi.fn() + + const { handle } = await import('./hooks.server.js') + const resolve = vi.fn[0]['resolve']>(async () => new Response('resolved')) const request = event({ user: { name: 'test' } }, pathname, options) + if (options.method === 'GET') { await expect(handle({ event: request, resolve })).rejects.toMatchObject({ status: 302, @@ -262,10 +283,12 @@ test.each(encodedRequests)( }) } else { const response = await handle({ event: request, resolve }) + expect(response.status).toBe(403) expect(response.headers.has('location')).toBe(false) expect(await response.json()).toEqual({ message: 'Forbidden' }) } + expect(request.locals.auth).not.toHaveBeenCalled() expect(resolve).not.toHaveBeenCalled() } @@ -274,10 +297,11 @@ test.each(encodedRequests)( test.each(encodedRequests)( 'authenticated $pathname resolves normally (data=$isDataRequest)', async ({ pathname, ...options }) => { - const { handle } = await import('./hooks.server') + const { handle } = await import('./hooks.server.js') const response = new Response('resolved') - const resolve = vi.fn().mockResolvedValue(response) + const resolve = vi.fn[0]['resolve']>().mockResolvedValue(response) const request = event({ user: { name: 'test' } }, pathname, options) + await expect(handle({ event: request, resolve })).resolves.toBe(response) expect(request.locals.auth).toHaveBeenCalledOnce() expect(resolve).toHaveBeenCalledExactlyOnceWith(request) @@ -295,10 +319,11 @@ test.each([ { pathname: '/admin-looking-group', routeId: '/(auth)extra/admin' }, { pathname: '/admin/not-a-route', routeId: null } ])('public/unmatched $routeId is not classified by URL spelling', async ({ pathname, routeId }) => { - const { handle } = await import('./hooks.server') + const { handle } = await import('./hooks.server.js') const response = new Response('public or not found') - const resolve = vi.fn().mockResolvedValue(response) + const resolve = vi.fn[0]['resolve']>().mockResolvedValue(response) const request = event(null, pathname, { routeId }) + await expect(handle({ event: request, resolve })).resolves.toBe(response) expect(request.locals.auth).not.toHaveBeenCalled() expect(resolve).toHaveBeenCalledExactlyOnceWith(request) @@ -308,7 +333,9 @@ test.each([true, false])( 'non-navigation requests get generic errors (auth=%s)', async (enabled) => { fixture.config.auth = enabled ? credentialsAuth : undefined - const { handle } = await import('./hooks.server') + + const { handle } = await import('./hooks.server.js') + for (const pathname of ['/admin/tools', '/api/admin/generate', '/future-tool']) { for (const [method, accept] of [ ['POST', 'application/json'], @@ -318,7 +345,9 @@ test.each([true, false])( ['HEAD', 'text/html'] ]) { const request = event(null, pathname, { method, headers: { accept } }) - const resolve = vi.fn() + const resolve = vi.fn[0]['resolve']>( + async () => new Response('resolved') + ) const response = await handle({ event: request, resolve }) expect(response.status).toBe(enabled ? 401 : 403) expect(response.headers.get('Cache-Control')).toBe('private, no-store') @@ -335,17 +364,18 @@ test.each([true, false])( 'enhanced forms keep their existing redirects (auth=%s)', async (enabled) => { fixture.config.auth = enabled ? credentialsAuth : undefined - const { handle } = await import('./hooks.server') + const { handle } = await import('./hooks.server.js') const pathname = '/admin/piece/books/book.books.md/source' const request = event(null, pathname, { method: 'POST', headers: { accept: 'application/json', 'x-sveltekit-action': 'true' } }) - const resolve = vi.fn() + const resolve = vi.fn[0]['resolve']>(async () => new Response('resolved')) await expect(handle({ event: request, resolve })).rejects.toMatchObject({ status: 302, location: enabled ? `/signin?${new URLSearchParams({ redirectTo: pathname })}` : '/' }) + expect(resolve).not.toHaveBeenCalled() } ) @@ -355,6 +385,6 @@ test.each([ { auth: oidcAuth, provider: 'oidc' } ])('initializes the native core with the configured $provider provider', async ({ auth }) => { fixture.config.auth = auth - await import('./hooks.server') + await import('./hooks.server.js') expect(fixture.createAuth).toHaveBeenCalledExactlyOnceWith(auth, fixture.config.url.app) }) diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index edace10c..427821f5 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -1,12 +1,12 @@ -import { building } from '$app/environment' -import { config } from '$lib/server/config' -import { createAuth, createAuthHandle } from '$lib/server/auth' +import { redirect } from '@sveltejs/kit' +import { building } from '$app/env' +import { config } from '#lib/server/config.js' +import { createAuth, createAuthHandle } from '#lib/server/auth/index.js' import { resolveAuthUrl } from '../scripts/runtime-environment.js' -import { createAssetCspHandle } from '$lib/server/asset-csp' -import { prepareConfiguredStyles, renderConfiguredStyles } from '$lib/server/configured-styles' -import '$lib/server/database' -import { sequence } from '@sveltejs/kit/hooks' -import { json, redirect, type Handle } from '@sveltejs/kit' +import { createAssetCspHandle } from '#lib/server/asset-csp.js' +import { prepareConfiguredStyles, renderConfiguredStyles } from '#lib/server/configured-styles.js' +import '#lib/server/database/index.js' +import { sequence, type Handle } from '@sveltejs/kit/hooks' async function prepareStartupStyles() { if (building) return '' @@ -24,8 +24,8 @@ function createAssetPolicy(): Handle { const configuredStyles = await prepareStartupStyles() const handleAssetCsp = createAssetPolicy() - const auth = config.auth + function initializeAuth() { if (building || !auth) return undefined return createAuth(auth, resolveAuthUrl(config.url.app)) @@ -40,7 +40,9 @@ const guardHandle: Handle = async ({ event, resolve }) => { if (!isProtectedRoute(event.route.id)) return resolve(event) const headers = { 'Cache-Control': 'private, no-store' } + event.setHeaders(headers) + const { request } = event const browserGet = request.method === 'GET' && @@ -51,14 +53,16 @@ const guardHandle: Handle = async ({ event, resolve }) => { if (!auth) { if (shouldRedirect) throw redirect(302, '/') - return json({ message: 'Forbidden' }, { status: 403, headers }) + return Response.json({ message: 'Forbidden' }, { status: 403, headers }) } const session = await event.locals.auth() - if (session) return resolve(event) - if (!shouldRedirect) return json({ message: 'Unauthorized' }, { status: 401, headers }) + if (session) return resolve(event) + if (!shouldRedirect) return Response.json({ message: 'Unauthorized' }, { status: 401, headers }) + const params = new URLSearchParams({ redirectTo: event.url.pathname + event.url.search }) + throw redirect(302, `/signin?${params}`) } diff --git a/apps/web/src/lib/components/editor/GenerationDialog.svelte b/apps/web/src/lib/components/editor/GenerationDialog.svelte index 7863ef1f..d94f02df 100644 --- a/apps/web/src/lib/components/editor/GenerationDialog.svelte +++ b/apps/web/src/lib/components/editor/GenerationDialog.svelte @@ -3,9 +3,9 @@ import { onDestroy, tick, untrack } from 'svelte' import { fade, fly } from 'svelte/transition' import CircleNotch from 'virtual:icons/ph/circle-notch-bold' - import Button from '$lib/components/ui/Button.svelte' + import Button from '#lib/components/ui/Button.svelte' import GenerationFields from './GenerationFields.svelte' - import { startGeneration } from '$lib/generation/client' + import { startGeneration } from '#lib/generation/client.js' type Props = { open: boolean diff --git a/apps/web/src/lib/components/editor/PieceToolbar.svelte b/apps/web/src/lib/components/editor/PieceToolbar.svelte index c3264448..ee83923f 100644 --- a/apps/web/src/lib/components/editor/PieceToolbar.svelte +++ b/apps/web/src/lib/components/editor/PieceToolbar.svelte @@ -1,7 +1,8 @@
@@ -38,7 +40,11 @@ {:else if isDirty} Unsaved {:else} - + Saved {/if} @@ -47,7 +53,7 @@ {#if isUnpublishing} Unpublishing… {:else if hasPublishedVersion} - Published + Published {:else} Unpublished {/if} diff --git a/apps/web/src/lib/components/editor/PieceToolbar.test.ts b/apps/web/src/lib/components/editor/PieceToolbar.test.ts index 6995a254..253743b1 100644 --- a/apps/web/src/lib/components/editor/PieceToolbar.test.ts +++ b/apps/web/src/lib/components/editor/PieceToolbar.test.ts @@ -1,8 +1,21 @@ import { createRawSnippet, type ComponentProps } from 'svelte' import { render } from 'svelte/server' -import { expect, test } from 'vitest' +import { beforeEach, expect, test, vi } from 'vitest' +import { resolve } from '$app/paths' +import { + encodeArchivePath, + previewHref as piecePreviewHref, + publishedHref as piecePublishedHref +} from '#lib/pieces/navigation.js' import PieceToolbar from './PieceToolbar.svelte' +vi.mock('$app/paths', () => ({ + resolve: vi.fn((route: string, params: { path: string }) => + route.replace('/(auth)', '').replace('[...path]', () => params.path) + ) +})) +beforeEach(() => vi.clearAllMocks()) + const file = 'books/源 #?%.books.md' const previewHref = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/preview' const publishedHref = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/published' @@ -107,4 +120,20 @@ test('editor actions do not duplicate archive navigation', () => { expect(output).not.toContain('Back to archive') }) -// SSR verifies labels, links and disabled/form semantics, not clicks or responsive layout. +test('encoded file paths enter the app resolver unchanged', () => { + const file = 'books/literal%2F 源 #?%.books.md' + const preview = piecePreviewHref(file) + const published = piecePublishedHref(file) + const output = html({ file }) + + expect(preview).toContain('literal%252F%20%E6%BA%90%20%23%3F%25.books.md') + expect(vi.mocked(resolve).mock.calls).toEqual([ + ['/(auth)/admin/piece/[...path]/preview', { path: encodeArchivePath(file) }], + ['/(auth)/admin/piece/[...path]/published', { path: encodeArchivePath(file) }] + ]) + expect(output).toContain(`href="${preview}"`) + expect(output).toContain(`href="${published}"`) +}) + +// Mocked SSR verifies resolver inputs, labels, links and disabled/form semantics, +// not browser/native resolution, clicks or responsive layout. diff --git a/apps/web/src/lib/components/layout/simple/NavBanner.svelte b/apps/web/src/lib/components/layout/simple/NavBanner.svelte index 5b97459d..d510ba2f 100644 --- a/apps/web/src/lib/components/layout/simple/NavBanner.svelte +++ b/apps/web/src/lib/components/layout/simple/NavBanner.svelte @@ -1,4 +1,5 @@ diff --git a/apps/web/src/lib/components/layout/simple/ThemeToggle.svelte b/apps/web/src/lib/components/layout/simple/ThemeToggle.svelte index e14217bc..8851ac73 100644 --- a/apps/web/src/lib/components/layout/simple/ThemeToggle.svelte +++ b/apps/web/src/lib/components/layout/simple/ThemeToggle.svelte @@ -2,7 +2,7 @@ import SunIcon from 'virtual:icons/ph/sun-dim' import MoonIcon from 'virtual:icons/ph/moon' import FaderIcon from 'virtual:icons/ph/faders' - import themes, { type Theme } from '$lib/ui/styles/themes' + import themes, { type Theme } from '#lib/ui/styles/themes.js' import { onMount } from 'svelte' function getThemePreference(): Theme { diff --git a/apps/web/src/lib/content/components/404.default.svelte b/apps/web/src/lib/content/components/404.default.svelte index fa8e2943..20f358bc 100644 --- a/apps/web/src/lib/content/components/404.default.svelte +++ b/apps/web/src/lib/content/components/404.default.svelte @@ -1,4 +1,5 @@
- + Published
diff --git a/apps/web/src/lib/pieces/viewer/ViewerHeader.test.ts b/apps/web/src/lib/pieces/viewer/ViewerHeader.test.ts index d1c252d4..a7230b18 100644 --- a/apps/web/src/lib/pieces/viewer/ViewerHeader.test.ts +++ b/apps/web/src/lib/pieces/viewer/ViewerHeader.test.ts @@ -1,19 +1,33 @@ import { readFileSync } from 'node:fs' import { beforeEach, expect, test, vi } from 'vitest' import { render } from 'svelte/server' -import type { PieceLinks } from '$lib/pieces/navigation' import ViewerHeader from './ViewerHeader.svelte' -const { page, resolve } = vi.hoisted(() => ({ - page: { url: new URL('https://app.test/') }, - resolve: vi.fn(() => { - throw new Error('Could not get the request store. This is an internal error.') - }) -})) +const { page, mount, resolve } = vi.hoisted(() => { + const mount = { prefix: '' } + return { + mount, + page: { + url: new URL('https://app.test/'), + route: { id: '/(auth)/admin/piece/[...path]/published' } + }, + resolve: vi.fn( + (route: string, params: { path: string }) => + `${mount.prefix}${route.replace('/(auth)', '').replace('[...path]', params.path)}` + ) + } +}) vi.mock('$app/state', () => ({ page })) vi.mock('$app/paths', () => ({ resolve })) -function links(root = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md'): PieceLinks { +// This resolver double tests delegation and encoded params, not native Kit/browser routing. +const file = 'books/源 #?%.books.md' +const encodedPath = 'books/%E6%BA%90%20%23%3F%25.books.md' +const publishedRoute = '/(auth)/admin/piece/[...path]/published' +const tabSuffixes = { overview: '', page: '/page', icon: '/icon', share: '/share' } + +function links(path = encodedPath, prefix = '') { + const root = `${prefix}/admin/piece/${path}` return { source: `${root}/source`, overview: `${root}/published`, @@ -23,17 +37,29 @@ function links(root = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md'): Piec } } +function expectNavigation(path = encodedPath) { + const routes = [ + '/(auth)/admin/piece/[...path]/source', + ...Object.values(tabSuffixes).map((suffix) => publishedRoute + suffix) + ] + expect(resolve).toHaveBeenCalledTimes(routes.length) + for (const route of routes) expect(resolve).toHaveBeenCalledWith(route, { path }) +} + beforeEach(() => { page.url = new URL('https://app.test/') + page.route.id = publishedRoute + mount.prefix = '' vi.clearAllMocks() }) test.each(['overview', 'page', 'icon', 'share'] as const)( - '%s is active using serialized loader links without a render request store', + '%s is active by route ID and delegates navigation to resolve', (tab) => { const navigation = links() page.url = new URL(navigation[tab], 'https://app.test') - const html = render(ViewerHeader, { props: { links: navigation } }).body + page.route.id = publishedRoute + tabSuffixes[tab] + const html = render(ViewerHeader, { props: { file } }).body expect(html).toContain(`href="${navigation.source}"`) expect(html).toContain('aria-label="Back to editor"') expect(html).toContain('title="Back to editor"') @@ -44,42 +70,69 @@ test.each(['overview', 'page', 'icon', 'share'] as const)( expect(html).not.toContain('Logs') expect(html).not.toContain(' { - const navigation = links('/garden/admin/piece/books/book.md') - page.url = new URL(navigation.icon, 'https://app.test') - const html = render(ViewerHeader, { props: { links: navigation } }).body - expect(html).toContain(`href="${navigation.icon}" aria-current="page"`) - expect(html).toContain(`href="${navigation.source}"`) - expect(resolve).not.toHaveBeenCalled() +test.each([ + [file, encodedPath], + ['folder with spaces/a book.books.md', 'folder%20with%20spaces/a%20book.books.md'], + ['nested/literal%2F.books.md', 'nested/literal%252F.books.md'], + ['books/📚.books.md', 'books/%F0%9F%93%9A.books.md'], + ['published/page/icon/share/source/file.md', 'published/page/icon/share/source/file.md'] +])('raw file %s becomes an encoded-once path param for all five routes', (raw, encoded) => { + // A different URL identity must not be used to derive the file or its mount. + page.url = new URL('https://app.test/garden/admin/piece/other.md/published/share?x=1#old') + page.route.id = publishedRoute + '/share' + const html = render(ViewerHeader, { props: { file: raw } }).body + for (const href of Object.values(links(encoded))) expect(html).toContain(`href="${href}"`) + expectNavigation(encoded) + expect(html).not.toContain('/garden/') + expect(html).not.toContain('other.md') }) -test('trailing slash still identifies the active view', () => { - const navigation = links() - page.url = new URL(navigation.overview + '/', 'https://app.test') - const html = render(ViewerHeader, { props: { links: navigation } }).body - expect(html).toContain(`href="${navigation.overview}" aria-current="page"`) +test.each(['overview', 'page', 'icon', 'share'] as const)( + 'controlled resolver prefix and trailing slash leave %s active by route ID', + (tab) => { + mount.prefix = '/garden' + const navigation = links(encodedPath, mount.prefix) + page.url = new URL(`${navigation[tab]}/?x=1#fragment`, 'https://app.test') + page.route.id = publishedRoute + tabSuffixes[tab] + const html = render(ViewerHeader, { props: { file } }).body + expect(html).toContain(`href="${navigation[tab]}" aria-current="page"`) + expect(html.match(/aria-current="page"/g)).toHaveLength(1) + expect(html).toContain(`href="${navigation.source}"`) + // Prefix handling belongs to resolve, not the raw file or its encoded param. + expectNavigation() + } +) + +test('active tab follows route ID rather than a coincidentally matching URL pathname', () => { + page.url = new URL(links().icon, 'https://app.test') + page.route.id = publishedRoute + '/page' + const html = render(ViewerHeader, { props: { file } }).body + expect(html).toContain(`href="${links().page}" aria-current="page"`) + expect(html).not.toContain(`href="${links().icon}" aria-current="page"`) + expect(html.match(/aria-current="page"/g)).toHaveLength(1) }) -test('replacement loader links do not retain a previous source identity', () => { - render(ViewerHeader, { props: { links: links('/admin/piece/first.md') } }) - const navigation = links('/admin/piece/second.md') - page.url = new URL(navigation.share, 'https://app.test') - const html = render(ViewerHeader, { props: { links: navigation } }).body +test('replacement file renders do not retain a previous source identity', () => { + render(ViewerHeader, { props: { file: 'first.md' } }) + vi.clearAllMocks() + page.url = new URL(links('second.md').share, 'https://app.test') + page.route.id = publishedRoute + '/share' + const html = render(ViewerHeader, { props: { file: 'second.md' } }).body expect(html).toContain('/second.md/published/share') expect(html).toContain('aria-current="page"') expect(html).not.toContain('first') + expectNavigation('second.md') }) test('back link uses the same decorative arrow icon as Preview, not a visible text label', () => { - const navigation = links() - const html = render(ViewerHeader, { props: { links: navigation } }).body + const html = render(ViewerHeader, { props: { file } }).body const anchors = html.match(/"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a>/g) ?? [] const back = anchors.find((anchor) => anchor.includes('aria-label="Back to editor"'))! - expect(back).toContain(`href="${navigation.source}"`) + expect(back).toContain(`href="${links().source}"`) expect(back).toContain('title="Back to editor"') expect(back).toMatch(/]*aria-hidden="true"/) expect(back).toMatch(/]*focusable="false"/) @@ -90,7 +143,7 @@ test('back link uses the same decorative arrow icon as Preview, not a visible te .trim() ).toBe('') expect(back).not.toContain('target=') - expect(resolve).not.toHaveBeenCalled() + expectNavigation() }) test('Published header consumes the narrowly scoped shared bar stylesheet', () => { @@ -141,7 +194,7 @@ test('shared bar stacks mode/back above full-width wrapping tabs on mobile, with expect(css).toContain('min-height: 44px') expect(mobile).not.toContain('display: none') expect(mobile).not.toContain('position: absolute') - const html = render(ViewerHeader, { props: { links: links() } }).body + const html = render(ViewerHeader, { props: { file } }).body expect(html).toContain('
@@ -20,7 +26,7 @@

assets:

{#each files.assets as asset (asset.path)} - {asset.name} + {asset.name} {/each}
@@ -31,7 +37,7 @@

directories

{#each files.directories as dir (dir.path)} - {dir.name} + {dir.name} {/each}
diff --git a/apps/web/src/routes/(auth)/admin/directory/[...directory]/page.server.test.ts b/apps/web/src/routes/(auth)/admin/directory/[...directory]/page.server.test.ts index 02798e8e..8258ad78 100644 --- a/apps/web/src/routes/(auth)/admin/directory/[...directory]/page.server.test.ts +++ b/apps/web/src/routes/(auth)/admin/directory/[...directory]/page.server.test.ts @@ -9,7 +9,7 @@ const { getFilesIn, parseFilename } = vi.hoisted(() => ({ })), parseFilename: vi.fn((file: string) => ({ file })) })) -vi.mock('$lib/server/pieces', () => ({ getPieces: () => ({ getFilesIn, parseFilename }) })) +vi.mock('#lib/server/pieces.js', () => ({ getPieces: () => ({ getFilesIn, parseFilename }) })) test('directory loader keeps browsing data without the unused mode marker', async () => { const event = { params: { directory: 'books' } } as Parameters[0] diff --git a/apps/web/src/routes/(auth)/admin/directory/[...directory]/page.test.ts b/apps/web/src/routes/(auth)/admin/directory/[...directory]/page.test.ts new file mode 100644 index 00000000..8fba52bb --- /dev/null +++ b/apps/web/src/routes/(auth)/admin/directory/[...directory]/page.test.ts @@ -0,0 +1,62 @@ +import { expect, test, vi } from 'vitest' +import { render } from 'svelte/server' +import type { ComponentProps } from 'svelte' +import { makeConfig } from '#lib/server/config.fixture.js' +import Page from './+page.svelte' + +const { resolve } = vi.hoisted(() => ({ + resolve: vi.fn((route: string, params?: { path: string }) => { + const path = route.replace('/(auth)', '').replace('[...path]', () => params?.path ?? '') + return path.startsWith('/') ? path : `/${path}` + }) +})) +vi.mock('$app/paths', () => ({ resolve })) + +// Mocked SSR proves native route params, encoded application paths and rendered +// targets, not native Kit resolution or browser navigation. +test('directory links encode segments once and retain nested separators and trailing slash', () => { + const props = { + data: { + config: makeConfig(), + meta: { title: 'Directory' }, + pieceTypes: ['books'], + directory: { parent: '棚 #?%', current: '棚 #?%/literal%2F' }, + files: { + pieces: [ + { + file: '棚 #?%/literal%2F/源 #?%.books.md', + slug: 'Piece', + type: 'books', + format: '.md' + } + ], + assets: [{ path: '棚 #?%/literal%2F/.assets/源 #?%.png', name: 'Asset' }], + directories: [{ path: '棚 #?%/literal%2F/子 #?%/', name: 'Directory' }] + } + } + } satisfies ComponentProps + const targets = [ + 'admin/piece/%E6%A3%9A%20%23%3F%25/literal%252F/%E6%BA%90%20%23%3F%25.books.md/source', + 'admin/asset/editor/%E6%A3%9A%20%23%3F%25/literal%252F/.assets/%E6%BA%90%20%23%3F%25.png', + 'admin/directory/%E6%A3%9A%20%23%3F%25/literal%252F/%E5%AD%90%20%23%3F%25/' + ] + const html = render(Page, { props }).body.replace(//g, '') + const links = html.match(/]*>[\s\S]*?<\/a>/g) ?? [] + expect(links).toHaveLength(3) + for (const [index, label] of ['Piece', 'Asset', 'Directory'].entries()) { + expect(links[index]).toContain(`href="/${targets[index]}"`) + expect(links[index]).toContain(`>${label}`) + } + // Native piece params and asset/directory pathstrings are already encoded; + // separators and the directory's final slash are left intact. + expect(resolve.mock.calls).toEqual([ + [ + '/(auth)/admin/piece/[...path]/source', + { + path: '%E6%A3%9A%20%23%3F%25/literal%252F/%E6%BA%90%20%23%3F%25.books.md' + } + ], + [targets[1]], + [targets[2]] + ]) +}) diff --git a/apps/web/src/routes/(auth)/admin/health/+page.server.ts b/apps/web/src/routes/(auth)/admin/health/+page.server.ts index 72445087..80e8f491 100644 --- a/apps/web/src/routes/(auth)/admin/health/+page.server.ts +++ b/apps/web/src/routes/(auth)/admin/health/+page.server.ts @@ -1,12 +1,12 @@ -import { config } from '$lib/server/config' -import { probeStorage } from '$lib/server/health/probes.js' -import { testConnectivity } from '$lib/server/health/connectivity.js' -import { validateAiKeyIfConfigured } from '$lib/server/health/ai.js' +import { config } from '#lib/server/config.js' +import { probeStorage } from '#lib/server/health/probes.js' +import { testConnectivity } from '#lib/server/health/connectivity.js' +import { validateAiKeyIfConfigured } from '#lib/server/health/ai.js' import { loadHealthPage, probeWorkerIfConfigured, probeOidcIssuerIfConfigured -} from '$lib/server/load/health.js' +} from '#lib/server/load/health.js' import type { Actions, PageServerLoad } from './$types' export const load: PageServerLoad = () => loadHealthPage() diff --git a/apps/web/src/routes/(auth)/admin/health/+page.svelte b/apps/web/src/routes/(auth)/admin/health/+page.svelte index ded1abcd..1584e07c 100644 --- a/apps/web/src/routes/(auth)/admin/health/+page.svelte +++ b/apps/web/src/routes/(auth)/admin/health/+page.svelte @@ -1,6 +1,5 @@ @@ -14,5 +14,5 @@ - + {@render children()} diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/+page.svelte b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/+page.svelte index 7c0fb3c8..dd71490a 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/+page.svelte +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/+page.svelte @@ -1,8 +1,8 @@ diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/layout.server.test.ts b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/layout.server.test.ts index f5368b10..004f70fa 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/layout.server.test.ts +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/layout.server.test.ts @@ -1,5 +1,5 @@ import { beforeEach, expect, test, vi } from 'vitest' -import { MarkdownPublicationStatus } from '$lib/pieces/types' +import { MarkdownPublicationStatus } from '#lib/pieces/types.js' import { load } from './+layout.server' const { @@ -46,11 +46,11 @@ vi.mock('$app/paths', () => ({ throw new Error('Could not get the request store. This is an internal error.') } })) -vi.mock('$lib/server/database', () => ({ db })) -vi.mock('$lib/server/config', () => ({ config })) -vi.mock('$lib/server/pieces', () => ({ getWebPiece, getPieces })) -vi.mock('$lib/server/storage', () => ({ getStorage })) -vi.mock('$lib/server/workflow/index.js', () => ({ getOpenWorkflow: () => ({ runWorkflow }) })) +vi.mock('#lib/server/database/index.js', () => ({ db })) +vi.mock('#lib/server/config.js', () => ({ config })) +vi.mock('#lib/server/pieces.js', () => ({ getWebPiece, getPieces })) +vi.mock('#lib/server/storage.js', () => ({ getStorage })) +vi.mock('#lib/server/workflow/index.js', () => ({ getOpenWorkflow: () => ({ runWorkflow }) })) const file = 'old type/源 #?%.books.md' const serving = { @@ -73,8 +73,7 @@ function event() { locals: { inlineConfiguredStyles: vi.fn() }, url: new URL( 'https://admin.example/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published' - ), - route: { id: '/(auth)/admin/piece/[...path]/published' } + ) } as unknown as Parameters[0] } @@ -114,18 +113,10 @@ test('loads only serving data/public assets, distinct tags and actual public URL }, tags, publication: { lastPublishedAt: 1234, markdownStatus: MarkdownPublicationStatus.MATCHING }, - publishedUrl: 'https://app.example/garden/pieces/removed-schema/public%20%23%3F%25', - links: { - source: '/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/source', - overview: '/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published', - page: '/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published/page', - icon: '/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published/icon', - share: '/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published/share' - } + publishedUrl: 'https://app.example/garden/pieces/removed-schema/public%20%23%3F%25' }) expect(Object.keys(data!).sort()).toEqual([ 'file', - 'links', 'piece', 'publication', 'publishedUrl', @@ -251,26 +242,6 @@ test('public URL encodes both serving type and slug, including literal percent c }) }) -test.each(['', '/page', '/icon', '/share'])( - 'loader serializes mounted navigation on %s without a Kit request store', - async (suffix) => { - const request = { - ...event(), - url: new URL( - `https://admin.example/garden/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published${suffix}/?x=1` - ), - route: { id: '/(auth)/admin/piece/[...path]/published' + suffix } - } as Parameters[0] - expect(await load(request)).toMatchObject({ - links: { - source: '/garden/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/source', - overview: '/garden/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published', - share: '/garden/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published/share' - } - }) - } -) - test('cache database failures follow normal loader failure semantics', async () => { query.executeTakeFirst.mockRejectedValue(new Error('Database unavailable')) await expect(load(event())).rejects.toThrow('Database unavailable') diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/page.test.ts b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/page.test.ts index f8ca7ec4..0601b7c5 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/page.test.ts +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/page.test.ts @@ -2,8 +2,12 @@ import { readFileSync } from 'node:fs' import { beforeEach, expect, test, vi } from 'vitest' import { render } from 'svelte/server' import type { ComponentProps } from 'svelte' -import { MarkdownPublicationStatus } from '$lib/pieces/types' -import { publishedPiece as rawPublishedPiece, asset } from '$lib/pieces/viewer/fixtures/published' +import { MarkdownPublicationStatus } from '#lib/pieces/types.js' +import { editorHref, publishedHref } from '#lib/pieces/navigation.js' +import { + publishedPiece as rawPublishedPiece, + asset +} from '#lib/pieces/viewer/fixtures/published.js' import Overview from './+page.svelte' import PublishedTree from './fixtures/PublishedTree.svelte' @@ -16,28 +20,33 @@ const publishedPiece = { } } -const { page } = vi.hoisted(() => ({ +const { page, resolve } = vi.hoisted(() => ({ page: { - url: new URL('https://app.test/admin/piece/books/book.md/published'), + url: new URL('https://app.test/'), + route: { id: '/(auth)/admin/piece/[...path]/published' }, params: {}, data: { config: { url: { app: '/garden', luzzle_assets: '/cdn' } } } - } + }, + resolve: vi.fn((route: string, params?: { path?: string; tag?: string }) => { + if (route === '/tags/[tag]' && params?.tag !== undefined) return `/tags/${params.tag}` + if (route.startsWith('/(auth)/admin/piece/[...path]/') && params?.path !== undefined) + return route.replace('/(auth)', '').replace('[...path]', params.path) + if (params || route.includes('[')) throw new Error(`Unexpected route: ${route}`) + return route.startsWith('/') ? route : `/${route}` + }) })) vi.mock('$app/state', () => ({ page })) -vi.mock('$app/paths', () => ({ - resolve: (route: string, params: Record) => - route.replace(/\[(?:\.\.\.)?(\w+)\]/g, (_, name: string) => params[name]) -})) +vi.mock('$app/paths', () => ({ resolve })) // These synchronous renderer stubs establish route props, URLs and landmarks; -// they do not exercise the wrappers' static catalogs or client prop updates. -vi.mock('$lib/pieces/components/page.svelte', async () => ({ - default: (await import('$lib/pieces/viewer/fixtures/PageRenderer.svelte')).default +// they do not exercise native Kit resolution, the wrappers' static catalogs or client prop updates. +vi.mock('#lib/pieces/components/page.svelte', async () => ({ + default: (await import('#lib/pieces/viewer/fixtures/PageRenderer.svelte')).default })) -vi.mock('$lib/pieces/components/icon.svelte', async () => ({ - default: (await import('$lib/pieces/viewer/fixtures/IconRenderer.svelte')).default +vi.mock('#lib/pieces/components/icon.svelte', async () => ({ + default: (await import('#lib/pieces/viewer/fixtures/IconRenderer.svelte')).default })) -vi.mock('$lib/pieces/components/opengraph.svelte', async () => ({ - default: (await import('$lib/pieces/viewer/fixtures/Composition.svelte')).default +vi.mock('#lib/pieces/components/opengraph.svelte', async () => ({ + default: (await import('#lib/pieces/viewer/fixtures/Composition.svelte')).default })) function data( status: MarkdownPublicationStatus = MarkdownPublicationStatus.MATCHING, @@ -50,13 +59,6 @@ function data( tags: [{ slug: 'fiction', tag: 'Fiction' }], publication: { lastPublishedAt: time, markdownStatus: status }, publishedUrl: 'https://public.test/garden/pieces/books/published%20%23%3F%25', - links: { - source: '/admin/piece/books/book.md/source', - overview: '/admin/piece/books/book.md/published', - page: '/admin/piece/books/book.md/published/page', - icon: '/admin/piece/books/book.md/published/icon', - share: '/admin/piece/books/book.md/published/share' - }, config: { content: { text: { title: 'Garden', description: 'Description' } }, url: { app: '/garden', app_assets: '/static', luzzle_assets: '/cdn' } @@ -65,7 +67,9 @@ function data( } } beforeEach(() => { - page.url = new URL('https://app.test/admin/piece/books/book.md/published') + vi.clearAllMocks() + page.url = new URL(publishedHref(data().file), 'https://app.test') + page.route.id = '/(auth)/admin/piece/[...path]/published' }) test('Overview shows escaped published identity, actual completion time and external public link', () => { @@ -87,7 +91,7 @@ test('Overview shows escaped published identity, actual completion time and exte 'href="https://public.test/garden/pieces/books/published%20%23%3F%25" target="_blank" rel="noopener noreferrer"' ) expect(html).toContain(`${data().publishedUrl}`) - expect(html).not.toContain('href="/admin/piece/books/book.md/source"') + expect(html).not.toContain(`href="${editorHref(data().file)}"`) expect(html).not.toContain('>Open<') expect(html).not.toContain(']*dir="ltr"[^>]*>/) expect(link).not.toContain('aria-label=') } - expect(html).not.toContain(props.links.source) + expect(html).not.toContain(editorHref(props.file)) expect(html).not.toContain('nested folder/') expect(links[0]).toContain(`href="${props.publishedUrl}"`) expect(links[0]).toContain(`>${props.publishedUrl}`) @@ -209,7 +213,7 @@ test('Overview, not Share, links only the actual recorded generated output', () /]*href="\/cdn\/pieces\/assets\/piece\/generated.png"[^>]*>/ )?.[0] expect(generatedLink).toContain('target="_blank"') - expect(generatedLink).toContain('rel="noopener noreferrer"') + expect(generatedLink).toContain('rel="external noopener noreferrer"') expect(html).toMatch(/]*href="\/cdn\/pieces\/assets\/piece\/generated.png"[^>]*>View<\/a>/) expect(html).not.toContain('>Open<') expect(html).not.toContain(']*>Source<\/th>/) expect(result.body.match(/
{ + page.url = new URL(`${publishedHref(data().file)}/${view}`, 'https://app.test') + page.route.id = `/(auth)/admin/piece/[...path]/published/${view}` const result = render(PublishedTree, { props: { data: data(), view } }) + expect(result.body).toContain( + `href="${publishedHref(data().file)}/${view}" aria-current="page"` + ) + expect(result.body.match(/aria-current="page"/g)).toHaveLength(1) expect(result.body.match(/
- import PiecePage from '$lib/pieces/components/page.svelte' + import PiecePage from '#lib/pieces/components/page.svelte' import type { PageProps } from './$types' let { data }: PageProps = $props() diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/share/+page.svelte b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/share/+page.svelte index afe95221..3a76f930 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/share/+page.svelte +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/share/+page.svelte @@ -1,5 +1,5 @@ diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/source/+page.server.ts b/apps/web/src/routes/(auth)/admin/piece/[...path]/source/+page.server.ts index e30c3439..ff3ebb80 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/source/+page.server.ts +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/source/+page.server.ts @@ -1,10 +1,10 @@ import { error, fail, redirect } from '@sveltejs/kit' import type { Actions, PageServerLoad } from './$types' -import { getPieces, getWebPiece } from '$lib/server/pieces' -import { getStorage } from '$lib/server/storage' -import { db } from '$lib/server/database' +import { getPieces, getWebPiece } from '#lib/server/pieces.js' +import { getStorage } from '#lib/server/storage.js' +import { db } from '#lib/server/database/index.js' import path from 'path' -import { config } from '$lib/server/config' +import { config } from '#lib/server/config.js' import { asCoreDatabase, extractFullMarkdown, @@ -18,7 +18,7 @@ import { savePieceAsset } from '@luzzle/core' import { Readable } from 'stream' -import { normalizeLineEndings, normalizeMarkdown } from '$lib/server/markdown' +import { normalizeLineEndings, normalizeMarkdown } from '#lib/server/markdown.js' async function resolveAssetUrls( piece: Piece, diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/source/+page.svelte b/apps/web/src/routes/(auth)/admin/piece/[...path]/source/+page.svelte index 5b0ec152..59899e48 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/source/+page.svelte +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/source/+page.svelte @@ -1,43 +1,53 @@ -{#if page.status === 404} - +{#if error.status === 404} + {:else} - + {/if} diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts index 55c39d30..2493edca 100644 --- a/apps/web/src/routes/+layout.server.ts +++ b/apps/web/src/routes/+layout.server.ts @@ -1,5 +1,5 @@ -import { config, type AppConfigPublic } from '$lib/server/config' -import { getConfiguredPieceTypes } from '$lib/pieces/types.server.js' +import { config, type AppConfigPublic } from '#lib/server/config.js' +import { getConfiguredPieceTypes } from '#lib/pieces/types.server.js' import type { LayoutServerLoad } from './$types' export const load: LayoutServerLoad = async ({ locals }) => { diff --git a/apps/web/src/routes/api/health/+server.ts b/apps/web/src/routes/api/health/+server.ts index bdd16edb..45db1b49 100644 --- a/apps/web/src/routes/api/health/+server.ts +++ b/apps/web/src/routes/api/health/+server.ts @@ -1,5 +1,5 @@ -import { json, type RequestHandler } from '@sveltejs/kit' +import type { RequestHandler } from '@sveltejs/kit' export const GET: RequestHandler = async () => { - return json({ status: 'ok', timestamp: new Date().toISOString() }) + return Response.json({ status: 'ok', timestamp: new Date().toISOString() }) } diff --git a/apps/web/src/routes/api/pieces/+server.ts b/apps/web/src/routes/api/pieces/+server.ts index b07df073..7f29bee7 100644 --- a/apps/web/src/routes/api/pieces/+server.ts +++ b/apps/web/src/routes/api/pieces/+server.ts @@ -1,7 +1,7 @@ -import { json, type RequestHandler } from '@sveltejs/kit' -import { db, sql } from '$lib/server/database' -import { isConfiguredPieceType } from '$lib/pieces/types.server.js' -import { hydrateWithAssets } from '$lib/pieces/assets.server' +import type { RequestHandler } from '@sveltejs/kit' +import { db, sql } from '#lib/server/database/index.js' +import { isConfiguredPieceType } from '#lib/pieces/types.server.js' +import { hydrateWithAssets } from '#lib/pieces/assets.server.js' import type { WebPieces } from '@luzzle/web.db' const TAKE_DEFAULT = 50 @@ -70,7 +70,7 @@ export const GET: RequestHandler = async ({ request }) => { .execute() ) - return json({ + return Response.json({ pieces }) } else { @@ -88,7 +88,7 @@ export const GET: RequestHandler = async ({ request }) => { pieces.pop() } - return json({ + return Response.json({ pieces, prevPage: pageNumber > 1 ? pageNumber - 1 : null, nextPage: hasMore ? pageNumber + 1 : null diff --git a/apps/web/src/routes/api/pieces/server.test.ts b/apps/web/src/routes/api/pieces/server.test.ts index 67228433..a72774df 100644 --- a/apps/web/src/routes/api/pieces/server.test.ts +++ b/apps/web/src/routes/api/pieces/server.test.ts @@ -15,9 +15,9 @@ const { config, selectFrom, query, hydrateWithAssets, sql } = vi.hoisted(() => ( hydrateWithAssets: vi.fn(), sql: vi.fn() })) -vi.mock('$lib/server/config', () => ({ config })) -vi.mock('$lib/server/database', () => ({ db: { selectFrom }, sql })) -vi.mock('$lib/pieces/assets.server', () => ({ hydrateWithAssets })) +vi.mock('#lib/server/config.js', () => ({ config })) +vi.mock('#lib/server/database/index.js', () => ({ db: { selectFrom }, sql })) +vi.mock('#lib/pieces/assets.server.js', () => ({ hydrateWithAssets })) function event(search = '') { return { diff --git a/apps/web/src/routes/api/search/+server.ts b/apps/web/src/routes/api/search/+server.ts index 4285fec2..8d33ec05 100644 --- a/apps/web/src/routes/api/search/+server.ts +++ b/apps/web/src/routes/api/search/+server.ts @@ -1,6 +1,6 @@ -import { json, type RequestHandler } from '@sveltejs/kit' -import { db, sql } from '$lib/server/database' -import { hydrateWithAssets } from '$lib/pieces/assets.server' +import type { RequestHandler } from '@sveltejs/kit' +import { db, sql } from '#lib/server/database/index.js' +import { hydrateWithAssets } from '#lib/pieces/assets.server.js' const MAX_RESULTS = 20 @@ -36,7 +36,7 @@ export const GET: RequestHandler = async ({ request }) => { const pieces = await hydrateWithAssets(webPieces) - return json({ + return Response.json({ pieces }) } diff --git a/apps/web/src/routes/api/session/verify/+server.ts b/apps/web/src/routes/api/session/verify/+server.ts index dcb60ef8..c1cb185d 100644 --- a/apps/web/src/routes/api/session/verify/+server.ts +++ b/apps/web/src/routes/api/session/verify/+server.ts @@ -1,5 +1,4 @@ -import { json } from '@sveltejs/kit' -import { config } from '$lib/server/config' +import { config } from '#lib/server/config.js' import type { RequestEvent } from './$types' export async function GET(event: RequestEvent) { @@ -13,5 +12,5 @@ export async function GET(event: RequestEvent) { return new Response('Unauthorized', { status: 401, headers }) } - return json({ status: 'ok' }, { headers }) + return Response.json({ status: 'ok' }, { headers }) } diff --git a/apps/web/src/routes/api/session/verify/server.test.ts b/apps/web/src/routes/api/session/verify/server.test.ts index 47f05fae..f3640f46 100644 --- a/apps/web/src/routes/api/session/verify/server.test.ts +++ b/apps/web/src/routes/api/session/verify/server.test.ts @@ -1,10 +1,10 @@ import { beforeEach, expect, test, vi } from 'vitest' -import { config } from '$lib/server/config' -import { credentialsAuth, oidcAuth } from '$lib/server/config.fixture' +import { config } from '#lib/server/config.js' +import { credentialsAuth, oidcAuth } from '#lib/server/config.fixture.js' import { GET } from './+server' -vi.mock('$lib/server/config', async () => { - const { makeConfig } = await import('$lib/server/config.fixture') +vi.mock('#lib/server/config.js', async () => { + const { makeConfig } = await import('#lib/server/config.fixture.js') return { config: makeConfig() } }) @@ -47,6 +47,7 @@ test.each([credentialsAuth, oidcAuth])('configured auth requires a session', asy expect(denied.headers.get('Cache-Control')).toBe('private, no-store') const response = await GET(event({ user: { name: 'admin' } })) expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/json') expect(response.headers.get('Cache-Control')).toBe('private, no-store') expect(await response.json()).toEqual({ status: 'ok' }) }) diff --git a/apps/web/src/routes/layout.server.test.ts b/apps/web/src/routes/layout.server.test.ts index 154b0d50..6c56bae9 100644 --- a/apps/web/src/routes/layout.server.test.ts +++ b/apps/web/src/routes/layout.server.test.ts @@ -1,8 +1,8 @@ import { expect, test, vi } from 'vitest' import { load } from './+layout.server' -vi.mock('$lib/server/config', async () => { - const { makeConfig: baseConfig, oidcAuth } = await import('$lib/server/config.fixture') +vi.mock('#lib/server/config.js', async () => { + const { makeConfig: baseConfig, oidcAuth } = await import('#lib/server/config.fixture.js') return { config: { ...baseConfig(), diff --git a/apps/web/src/routes/pieces/+layout.svelte b/apps/web/src/routes/pieces/+layout.svelte index e8208688..e17f9d8a 100644 --- a/apps/web/src/routes/pieces/+layout.svelte +++ b/apps/web/src/routes/pieces/+layout.svelte @@ -1,17 +1,20 @@ {#snippet rightItems()} {#if page.params.piece} - {:else} - + {/if} {/snippet} diff --git a/apps/web/src/routes/pieces/[[piece]]/+page.server.ts b/apps/web/src/routes/pieces/[[piece]]/+page.server.ts index c62c8fef..ac9292e7 100644 --- a/apps/web/src/routes/pieces/[[piece]]/+page.server.ts +++ b/apps/web/src/routes/pieces/[[piece]]/+page.server.ts @@ -1,8 +1,8 @@ -import { db, sql } from '$lib/server/database' +import { db, sql } from '#lib/server/database/index.js' import { error, redirect } from '@sveltejs/kit' -import { isConfiguredPieceType } from '$lib/pieces/types.server.js' +import { isConfiguredPieceType } from '#lib/pieces/types.server.js' import type { PageServerLoad } from './$types' -import { hydrateWithAssets } from '$lib/pieces/assets.server' +import { hydrateWithAssets } from '#lib/pieces/assets.server.js' const TAKE_DEFAULT = 50 diff --git a/apps/web/src/routes/pieces/[[piece]]/+page.svelte b/apps/web/src/routes/pieces/[[piece]]/+page.svelte index cfd3ebaa..883a0a3d 100644 --- a/apps/web/src/routes/pieces/[[piece]]/+page.svelte +++ b/apps/web/src/routes/pieces/[[piece]]/+page.svelte @@ -1,12 +1,15 @@ diff --git a/apps/web/src/routes/pieces/[piece]/[slug]/page.server.test.ts b/apps/web/src/routes/pieces/[piece]/[slug]/page.server.test.ts index 15d1d35c..4b2a5952 100644 --- a/apps/web/src/routes/pieces/[piece]/[slug]/page.server.test.ts +++ b/apps/web/src/routes/pieces/[piece]/[slug]/page.server.test.ts @@ -17,9 +17,9 @@ const { query, hydrateWithAssets, config } = vi.hoisted(() => ({ content: { text: { title: 'Garden' } } } })) -vi.mock('$lib/server/database', () => ({ db: { selectFrom: () => query } })) -vi.mock('$lib/server/config', () => ({ config })) -vi.mock('$lib/pieces/assets.server', () => ({ hydrateWithAssets })) +vi.mock('#lib/server/database/index.js', () => ({ db: { selectFrom: () => query } })) +vi.mock('#lib/server/config.js', () => ({ config })) +vi.mock('#lib/pieces/assets.server.js', () => ({ hydrateWithAssets })) function event() { return { diff --git a/apps/web/src/routes/pieces/assets/[...path]/+server.ts b/apps/web/src/routes/pieces/assets/[...path]/+server.ts index 09f75408..bc4b728a 100644 --- a/apps/web/src/routes/pieces/assets/[...path]/+server.ts +++ b/apps/web/src/routes/pieces/assets/[...path]/+server.ts @@ -3,7 +3,7 @@ import fs from 'node:fs' import { Readable } from 'node:stream' import mime from 'mime-types' import type { RequestHandler } from '@sveltejs/kit' -import { isSafeRelativePath, isWithinDirectory } from '$lib/server/asset-paths' +import { isSafeRelativePath, isWithinDirectory } from '#lib/server/asset-paths.js' export const GET: RequestHandler = async ({ params }) => { const { path: assetPath } = params diff --git a/apps/web/src/routes/random/+server.ts b/apps/web/src/routes/random/+server.ts index 5d10afae..d508ebfb 100644 --- a/apps/web/src/routes/random/+server.ts +++ b/apps/web/src/routes/random/+server.ts @@ -1,5 +1,5 @@ import { redirect } from '@sveltejs/kit' -import { db, sql } from '$lib/server/database' +import { db, sql } from '#lib/server/database/index.js' import type { RequestHandler } from './$types' export const GET: RequestHandler = async ({ url }) => { diff --git a/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/+server.ts b/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/+server.ts index df1fd875..021ef24c 100644 --- a/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/+server.ts +++ b/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/+server.ts @@ -1,10 +1,10 @@ import type { PublicWebPiece } from '@luzzle/web.pieces' import type { RequestHandler } from './$types' -import { config } from '$lib/server/config' +import { config } from '#lib/server/config.js' import { generateRssFeed, generateJsonFeed, type RssFeed, type JsonFeed } from 'feedsmith' -import { getPiecesForFeed } from '$lib/feeds/utils' +import { getPiecesForFeed } from '#lib/feeds/utils.js' import { error } from '@sveltejs/kit' -import { isConfiguredPieceType } from '$lib/pieces/types.server.js' +import { isConfiguredPieceType } from '#lib/pieces/types.server.js' import YAML from 'yaml' type FeedRss = RssFeed diff --git a/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/server.test.ts b/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/server.test.ts index 819f7073..2847a918 100644 --- a/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/server.test.ts +++ b/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/server.test.ts @@ -9,8 +9,8 @@ const { config, getPiecesForFeed } = vi.hoisted(() => ({ }, getPiecesForFeed: vi.fn() })) -vi.mock('$lib/server/config', () => ({ config })) -vi.mock('$lib/feeds/utils', () => ({ getPiecesForFeed })) +vi.mock('#lib/server/config.js', () => ({ config })) +vi.mock('#lib/feeds/utils.js', () => ({ getPiecesForFeed })) function event(type: string | undefined, format: string) { return { params: { piece: type, format } } as Parameters[0] diff --git a/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.server.ts b/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.server.ts index 2bfdcb28..d47b5f6d 100644 --- a/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.server.ts +++ b/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.server.ts @@ -1,8 +1,8 @@ -import { getPiecesForFeed } from '$lib/feeds/utils' -import { loadPageMarkdown } from '$lib/server/pages.js' -import { config } from '$lib/server/config' +import { getPiecesForFeed } from '#lib/feeds/utils.js' +import { loadPageMarkdown } from '#lib/server/pages.js' +import { config } from '#lib/server/config.js' import { error } from '@sveltejs/kit' -import { isConfiguredPieceType } from '$lib/pieces/types.server.js' +import { isConfiguredPieceType } from '#lib/pieces/types.server.js' import type { PageServerLoad } from './$types' export const load: PageServerLoad = async ({ params }) => { diff --git a/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.svelte b/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.svelte index 199c62fd..b29ca5ea 100644 --- a/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.svelte +++ b/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.svelte @@ -1,5 +1,5 @@ diff --git a/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/page.server.test.ts b/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/page.server.test.ts index 42b2d3ab..ffda6f2d 100644 --- a/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/page.server.test.ts +++ b/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/page.server.test.ts @@ -10,9 +10,9 @@ const { config, getPiecesForFeed, loadPageMarkdown } = vi.hoisted(() => ({ getPiecesForFeed: vi.fn(), loadPageMarkdown: vi.fn() })) -vi.mock('$lib/server/config', () => ({ config })) -vi.mock('$lib/feeds/utils', () => ({ getPiecesForFeed })) -vi.mock('$lib/server/pages.js', () => ({ loadPageMarkdown })) +vi.mock('#lib/server/config.js', () => ({ config })) +vi.mock('#lib/feeds/utils.js', () => ({ getPiecesForFeed })) +vi.mock('#lib/server/pages.js', () => ({ loadPageMarkdown })) const pieces = [{ id: 'note', type: 'field-notes', title: 'A note', assets: [] }] function event(type?: string) { diff --git a/apps/web/src/routes/rss/tags/[tag]/feed.[format=feedFormat]/+server.ts b/apps/web/src/routes/rss/tags/[tag]/feed.[format=feedFormat]/+server.ts index 203ec3d6..95985b66 100644 --- a/apps/web/src/routes/rss/tags/[tag]/feed.[format=feedFormat]/+server.ts +++ b/apps/web/src/routes/rss/tags/[tag]/feed.[format=feedFormat]/+server.ts @@ -1,8 +1,8 @@ import type { PublicWebPiece } from '@luzzle/web.pieces' import type { RequestHandler } from './$types' -import { config } from '$lib/server/config' +import { config } from '#lib/server/config.js' import { generateRssFeed, generateJsonFeed, type RssFeed, type JsonFeed } from 'feedsmith' -import { getPiecesForTagFeed } from '$lib/feeds/utils' +import { getPiecesForTagFeed } from '#lib/feeds/utils.js' import YAML from 'yaml' type FeedRss = RssFeed diff --git a/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.server.ts b/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.server.ts index 091e541e..13c801b9 100644 --- a/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.server.ts +++ b/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.server.ts @@ -1,6 +1,6 @@ -import { getPiecesForTagFeed } from '$lib/feeds/utils' -import { loadPageMarkdown } from '$lib/server/pages.js' -import { config } from '$lib/server/config' +import { getPiecesForTagFeed } from '#lib/feeds/utils.js' +import { loadPageMarkdown } from '#lib/server/pages.js' +import { config } from '#lib/server/config.js' import type { PageServerLoad } from './$types' export const load: PageServerLoad = async ({ params }) => { diff --git a/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.svelte b/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.svelte index 7319aa3f..3d4984c6 100644 --- a/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.svelte +++ b/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.svelte @@ -1,5 +1,5 @@ diff --git a/apps/web/src/routes/rss/tags/[tag]/feed.html/page.server.test.ts b/apps/web/src/routes/rss/tags/[tag]/feed.html/page.server.test.ts index 9ff49592..aecbd112 100644 --- a/apps/web/src/routes/rss/tags/[tag]/feed.html/page.server.test.ts +++ b/apps/web/src/routes/rss/tags/[tag]/feed.html/page.server.test.ts @@ -9,9 +9,9 @@ const { config, getPiecesForTagFeed, loadPageMarkdown } = vi.hoisted(() => ({ getPiecesForTagFeed: vi.fn(), loadPageMarkdown: vi.fn() })) -vi.mock('$lib/server/config', () => ({ config })) -vi.mock('$lib/feeds/utils', () => ({ getPiecesForTagFeed })) -vi.mock('$lib/server/pages.js', () => ({ loadPageMarkdown })) +vi.mock('#lib/server/config.js', () => ({ config })) +vi.mock('#lib/feeds/utils.js', () => ({ getPiecesForTagFeed })) +vi.mock('#lib/server/pages.js', () => ({ loadPageMarkdown })) const pieces = [{ id: 'note', type: 'field-notes', title: 'A note', assets: [] }] function event(tag = 'field-notes') { return { params: { tag } } as Parameters[0] diff --git a/apps/web/src/routes/search/+layout.svelte b/apps/web/src/routes/search/+layout.svelte index dc9a9d50..2c7a09d8 100644 --- a/apps/web/src/routes/search/+layout.svelte +++ b/apps/web/src/routes/search/+layout.svelte @@ -1,5 +1,5 @@ diff --git a/apps/web/src/routes/search/+page.server.ts b/apps/web/src/routes/search/+page.server.ts index 4c0bda60..8533f0cf 100644 --- a/apps/web/src/routes/search/+page.server.ts +++ b/apps/web/src/routes/search/+page.server.ts @@ -1,7 +1,7 @@ -import { db, sql } from '$lib/server/database' +import { db, sql } from '#lib/server/database/index.js' import { redirect } from '@sveltejs/kit' import type { PageServerLoad } from './$types' -import { hydrateWithAssets } from '$lib/pieces/assets.server' +import { hydrateWithAssets } from '#lib/pieces/assets.server.js' const MAX_RESULTS = 20 diff --git a/apps/web/src/routes/search/+page.svelte b/apps/web/src/routes/search/+page.svelte index 7a9c1fd4..66549a72 100644 --- a/apps/web/src/routes/search/+page.svelte +++ b/apps/web/src/routes/search/+page.svelte @@ -1,13 +1,15 @@ diff --git a/apps/web/src/routes/signin/+page.server.ts b/apps/web/src/routes/signin/+page.server.ts index 07059645..c2652598 100644 --- a/apps/web/src/routes/signin/+page.server.ts +++ b/apps/web/src/routes/signin/+page.server.ts @@ -1,7 +1,7 @@ import { redirect } from '@sveltejs/kit' import type { PageServerLoad } from './$types' -import { config } from '$lib/server/config' -import { localRedirect } from '$lib/server/redirect' +import { config } from '#lib/server/config.js' +import { localRedirect } from '#lib/server/redirect.js' export const load: PageServerLoad = async ({ locals, url }) => { const auth = config.auth diff --git a/apps/web/src/routes/signin/+page.svelte b/apps/web/src/routes/signin/+page.svelte index d260f81d..8fc90ea4 100644 --- a/apps/web/src/routes/signin/+page.svelte +++ b/apps/web/src/routes/signin/+page.svelte @@ -1,6 +1,6 @@ {#snippet rssIcon()} - + {/snippet}