diff --git a/.changeset/typebox-web-config.md b/.changeset/typebox-web-config.md new file mode 100644 index 00000000..3c425e01 --- /dev/null +++ b/.changeset/typebox-web-config.md @@ -0,0 +1,15 @@ +--- +"@luzzle/web": minor +--- + +Migrate the shared `@luzzle/web.config` contract used by `@luzzle/web` to TypeBox-authored schemas, inferred TypeScript types, and runtime defaulting/validation. Remove config's AJV and json2ts machinery; core retains AJV for piece schemas. Keep one generated editor artifact at `src/lib/config/web.config.schema.json`, preserving existing YAML modelines, and remove the former strict `schema.json`. + +This is a breaking configuration migration in 0.x: + +- Omit `auth` for a public-only site with admin access disabled. When configured, remove `auth.enabled` and `auth.type`, select exactly one of `credentials` or `oidc`, and explicitly supply a nonempty `secret` plus nonempty provider credentials. OIDC `name` still defaults to `Single Sign-On`; no new session-secret length minimum is imposed. +- Omit `ai` to disable generation, or explicitly supply `provider: google` and a nonempty `api_key`. Auth and AI environment references must be written explicitly in YAML. +- Missing `${VAR}` references anywhere now fail loading with variable-name/field-path diagnostics rather than secret values. `${VAR:-fallback}`, `$$` escaping, single-pass string expansion, and empty environment values counting as present remain supported. Resolved values are validated at runtime; the editor cannot inspect deployment variables. + +Update configuration and deployment documentation for input defaults versus resolved types, authentication and AI migration, actual `storage.root`/sync settings (no `builder`), path resolution, and standalone editor-schema generation via `build:external-schema`. + +The release is recorded against `@luzzle/web` because repository Changesets policy excludes the internal-only `@luzzle/web.config` package from versioning. diff --git a/apps/lsp/src/config-schema.test.ts b/apps/lsp/src/config-schema.test.ts new file mode 100644 index 00000000..ebed6aba --- /dev/null +++ b/apps/lsp/src/config-schema.test.ts @@ -0,0 +1,250 @@ +import { spawn } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { copyFile, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-jsonrpc/node.js"; +import type { MessageConnection } from "vscode-jsonrpc/node.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const credentials = + " credentials:\n username: admin\n password: ${PASSWORD}\n"; +const oidc = + " oidc:\n issuer: ${OIDC_ISSUER}\n clientId: client\n clientSecret: ${CLIENT_SECRET}\n"; +const auth = "auth:\n secret: ${SECRET}\n"; + +const fixtures: { name: string; yaml: string; errors: RegExp[] }[] = [ + { name: "minimal defaults, auth and AI omitted", yaml: "{}\n", errors: [] }, + { name: "credentials", yaml: auth + credentials, errors: [] }, + { + name: "OIDC without defaulted display name", + yaml: auth + oidc, + errors: [], + }, + { + name: "short nonempty secret", + yaml: "auth:\n secret: s\n" + credentials, + errors: [], + }, + { + name: "missing secret", + yaml: "auth:\n" + credentials, + errors: [/Missing property "secret"/], + }, + { + name: "empty secret", + yaml: 'auth:\n secret: ""\n' + credentials, + errors: [/minimum length of 1/], + }, + { + name: "missing password", + yaml: auth + " credentials:\n username: admin\n", + errors: [/Missing property "password"/], + }, + { + name: "both auth providers", + yaml: auth + credentials + oidc, + errors: [/Property (oidc|credentials) is not allowed/], + }, + { + name: "legacy auth enabled and type", + yaml: auth + " enabled: true\n type: credentials\n" + credentials, + errors: [/Property enabled is not allowed/, /Property type is not allowed/], + }, + { + name: "wrong secret type", + yaml: "auth:\n secret: 123\n" + credentials, + errors: [/Incorrect type.*string/], + }, + { + name: "configured AI", + yaml: "ai:\n provider: google\n api_key: ${GOOGLE_API_KEY}\n", + errors: [], + }, + { + name: "missing AI key", + yaml: "ai:\n provider: google\n", + errors: [/Missing property "api_key"/], + }, + { + name: "missing AI provider", + yaml: "ai:\n api_key: ${GOOGLE_API_KEY}\n", + errors: [/Missing property "provider"/], + }, + { + name: "AI fields have no implicit defaults", + yaml: "ai: {}\n", + errors: [/Missing property "provider"/, /Missing property "api_key"/], + }, + { + name: "unsupported AI provider", + yaml: "ai:\n provider: unsupported\n api_key: ${GOOGLE_API_KEY}\n", + errors: [/Value (is not accepted|must be).*google/], + }, +]; + +// Exercise the generated artifact through the actual editor protocol, not a validator +// or a re-created schema. No runtime configuration or deployment secrets are needed. +describe("generated web config schema in yaml-language-server", () => { + let directory: string | undefined; + let child: ChildProcessWithoutNullStreams | undefined; + let closed: Promise | undefined; + let connection: MessageConnection | undefined; + type Diagnostic = { message: string }; + const pending = new Map void>(); + + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), "luzzle-config-schema-")); + await copyFile( + new URL( + "../../../packages/web/config/src/lib/config/web.config.schema.json", + import.meta.url, + ), + join(directory, "web.config.schema.json"), + ); + child = spawn( + process.execPath, + [ + require.resolve("yaml-language-server/bin/yaml-language-server"), + "--stdio", + ], + { cwd: directory, stdio: ["pipe", "pipe", "pipe"] }, + ); + closed = new Promise((resolve) => child!.once("close", () => resolve())); + // Drain stderr so a server error cannot fill its pipe and stall the test. + child.stderr.resume(); + connection = createMessageConnection( + new StreamMessageReader(child.stdout), + new StreamMessageWriter(child.stdin), + ); + connection.onRequest( + "workspace/configuration", + ({ items }: { items: { section: string }[] }) => + items.map(({ section }) => + section === "yaml" + ? { + validate: true, + hover: true, + completion: true, + schemaStore: { enable: false }, + kubernetesCRDStore: { enable: false }, + schemas: {}, + } + : {}, + ), + ); + connection.onNotification( + "textDocument/publishDiagnostics", + ({ uri, diagnostics }: { uri: string; diagnostics: Diagnostic[] }) => + pending.get(uri)?.(diagnostics), + ); + connection.listen(); + await connection.sendRequest("initialize", { + processId: process.pid, + rootUri: pathToFileURL(directory).href, + capabilities: { + workspace: { configuration: true }, + textDocument: { + hover: { contentFormat: ["markdown", "plaintext"] }, + completion: { completionItem: { snippetSupport: true } }, + }, + }, + }); + await connection.sendNotification("initialized", {}); + }, 10_000); + + // Hooks also run on assertion failures/timeouts, including a failed handshake. + afterAll(async () => { + pending.clear(); + connection?.dispose(); + child?.kill("SIGKILL"); + await closed; + if (directory) await rm(directory, { recursive: true, force: true }); + }); + + it("validates the demo through its checked-in schema header", async () => { + const file = new URL("../../web/demo/config.yaml", import.meta.url); + const uri = file.href; + const text = await readFile(file, "utf8"); + const diagnostics = new Promise((resolve) => + pending.set(uri, resolve), + ); + try { + await connection!.sendNotification("textDocument/didOpen", { + textDocument: { uri, languageId: "yaml", version: 1, text }, + }); + expect(await diagnostics).toEqual([]); + } finally { + pending.delete(uri); + await connection!.sendNotification("textDocument/didClose", { + textDocument: { uri }, + }); + } + }, 10_000); + + it.each(fixtures)( + "$name", + async ({ name, yaml, errors }) => { + const file = join(directory!, `${name.replaceAll(" ", "-")}.yaml`); + const uri = pathToFileURL(file).href; + // No schema association is configured: only this relative modeline can load it. + const text = + "# yaml-language-server: $schema=./web.config.schema.json\n" + yaml; + await writeFile(file, text); + const diagnostics = new Promise((resolve) => + pending.set(uri, resolve), + ); + try { + await connection!.sendNotification("textDocument/didOpen", { + textDocument: { uri, languageId: "yaml", version: 1, text }, + }); + // Wait for this document's notification, not a sleep or another file's result. + const messages = (await diagnostics).map(({ message }) => message); + if (errors.length === 0) expect(messages).toEqual([]); + else { + for (const error of errors) { + expect(messages).toEqual( + expect.arrayContaining([expect.stringMatching(error)]), + ); + } + } + + if (name === "missing secret") { + type Item = { label: string }; + const completion = await connection!.sendRequest< + Item[] | { items: Item[] } | null + >("textDocument/completion", { + textDocument: { uri }, + position: { line: 2, character: 2 }, + }); + const items = Array.isArray(completion) + ? completion + : (completion?.items ?? []); + expect(items.map(({ label }) => label)).toContain("secret"); + } + if (name === "credentials") { + const hover = await connection!.sendRequest<{ + contents: unknown; + } | null>("textDocument/hover", { + textDocument: { uri }, + position: { line: 2, character: 4 }, + }); + expect(JSON.stringify(hover?.contents)).toMatch(/session|signing/i); + } + } finally { + pending.delete(uri); + await connection!.sendNotification("textDocument/didClose", { + textDocument: { uri }, + }); + } + }, + 10_000, + ); +}); diff --git a/apps/web/demo/config.yaml b/apps/web/demo/config.yaml index 0a0f8988..f343909a 100644 --- a/apps/web/demo/config.yaml +++ b/apps/web/demo/config.yaml @@ -9,8 +9,6 @@ network: worker: http://luzzle-dev-worker:9000 auth: - enabled: true - type: credentials secret: ${LUZZLE_AUTH_SECRET} credentials: username: admin diff --git a/apps/web/fnox.toml b/apps/web/fnox.toml index 34ba8c49..1aef02db 100644 --- a/apps/web/fnox.toml +++ b/apps/web/fnox.toml @@ -3,7 +3,7 @@ [secrets] # generate with: openssl rand -hex 32 LUZZLE_AUTH_SECRET = { default = "replace-me-with-a-secret" } -LUZZLE_AUTH_PASSWORD = { default = "luzzle" } +LUZZLE_AUTH_PASSWORD= { provider = "seahorse", value = "LUZZLE_AUTH_PASSWORD", default = "luzzle" } # generate with: openssl rand -hex 32 LUZZLE_BUILDER_TOKEN = { default = "replace-me-with-a-secret" } GOOGLE_API_KEY= { provider = "seahorse", value = "GOOGLE_API_KEY" } diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts index ded310e8..2ca28c99 100644 --- a/apps/web/src/hooks.server.test.ts +++ b/apps/web/src/hooks.server.test.ts @@ -1,55 +1,75 @@ -import { afterEach, expect, test, vi } from 'vitest' +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' const fixture = vi.hoisted(() => ({ config: { - auth: { enabled: true, type: 'credentials', secret: 'test-secret' }, + auth: undefined as Config['auth'], url: { app_assets: '' } - } + }, + createAuth: vi.fn(() => ({ + handle: (({ event, resolve }) => resolve(event)) satisfies Handle + })) })) vi.mock('$lib/server/config', () => ({ config: fixture.config })) vi.mock('$lib/server/database', () => ({})) // Exercise the application guard, not Auth.js or SvelteKit's request-store machinery. vi.mock('@sveltejs/kit/hooks', () => ({ sequence: (_auth: Handle, guard: Handle) => guard })) -vi.mock('@auth/sveltekit', () => ({ - SvelteKitAuth: () => ({ - handle: (({ event, resolve }) => resolve(event)) satisfies Handle - }) -})) +vi.mock('@auth/sveltekit', () => ({ SvelteKitAuth: fixture.createAuth })) -afterEach(() => { - fixture.config.auth.enabled = true - vi.resetModules() +beforeEach(() => { + fixture.config.auth = credentialsAuth + vi.clearAllMocks() }) +afterEach(() => vi.resetModules()) -function event(session: object | null) { +function event(session: object | null, pathname = '/api/admin/generate') { return { - url: new URL('http://localhost/api/admin/generate'), + url: new URL(`http://localhost${pathname}`), locals: { auth: vi.fn().mockResolvedValue(session) } } as unknown as Parameters[0]['event'] } -test('the generation namespace requires a session before resolving its handler', async () => { - const { handle } = await import('./hooks.server') - const resolve = vi.fn() - await expect(handle({ event: event(null), resolve })).rejects.toMatchObject({ - status: 302, - location: '/signin?redirectTo=/api/admin/generate' - }) - expect(resolve).not.toHaveBeenCalled() -}) +test.each(['/admin', '/admin/pieces', '/api/admin/generate', '/admin/lsp', '/administrator'])( + 'the protected prefix %s requires a session before resolving its handler', + async (pathname) => { + const { handle } = await import('./hooks.server') + const resolve = vi.fn() + await expect(handle({ event: event(null, pathname), resolve })).rejects.toMatchObject({ + status: 302, + location: `/signin?redirectTo=${pathname}` + }) + expect(resolve).not.toHaveBeenCalled() + } +) -test('generation remains inaccessible when admin authentication is disabled', async () => { - fixture.config.auth.enabled = false - const { handle } = await import('./hooks.server') - const resolve = vi.fn() - await expect(handle({ event: event({ user: { name: 'test' } }), resolve })).rejects.toMatchObject( - { +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 request = event({ user: { name: 'test' } }, pathname) + await expect(handle({ event: request, resolve })).rejects.toMatchObject({ status: 302, location: '/' - } - ) - expect(resolve).not.toHaveBeenCalled() + }) + expect(resolve).not.toHaveBeenCalled() + expect(request.locals.auth).not.toHaveBeenCalled() + expect(fixture.createAuth).not.toHaveBeenCalled() + } +) + +test('public-only requests resolve without constructing Auth.js or reading a session', async () => { + fixture.config.auth = undefined + const { handle } = await import('./hooks.server') + const request = event(null, '/') + const response = new Response('public') + const resolve = vi.fn().mockResolvedValue(response) + await expect(handle({ event: request, resolve })).resolves.toBe(response) + expect(request.locals.auth).not.toHaveBeenCalled() + expect(fixture.createAuth).not.toHaveBeenCalled() }) test('an authenticated generation request reaches its handler', async () => { @@ -60,3 +80,17 @@ test('an authenticated generation request reaches its handler', async () => { response ) }) + +test.each([ + { auth: credentialsAuth, provider: 'credentials' }, + { auth: oidcAuth, provider: 'oidc' } +])('installs only the configured $provider provider', async ({ auth, provider }) => { + fixture.config.auth = auth + await import('./hooks.server') + expect(fixture.createAuth).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + secret: auth.secret, + providers: [expect.objectContaining({ id: provider })] + }) + ) +}) diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index 6d83affc..f804d769 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -7,16 +7,17 @@ import { sequence } from '@sveltejs/kit/hooks' import { redirect, type Handle } from '@sveltejs/kit' import type { Provider } from '@auth/core/providers' +const auth = config.auth const providers: Provider[] = [] -if (config.auth.type === 'oidc' && config.auth.oidc) { +if (auth && 'oidc' in auth) { providers.push({ id: 'oidc', - name: config.auth.oidc.name, + name: auth.oidc.name, type: 'oidc', - issuer: config.auth.oidc.issuer, - clientId: config.auth.oidc.clientId, - clientSecret: config.auth.oidc.clientSecret, + issuer: auth.oidc.issuer, + clientId: auth.oidc.clientId, + clientSecret: auth.oidc.clientSecret, checks: ['state'], style: { logo: `${config.url.app_assets}/images/favicon.png` @@ -24,7 +25,8 @@ if (config.auth.type === 'oidc' && config.auth.oidc) { }) } -if (config.auth.type === 'credentials' && config.auth.credentials) { +if (auth && 'credentials' in auth) { + const user = auth.credentials providers.push( Credentials({ credentials: { @@ -32,13 +34,7 @@ if (config.auth.type === 'credentials' && config.auth.credentials) { password: { label: 'Password', type: 'password' } }, async authorize(credentials) { - const user = config.auth.credentials - - if ( - user && - user.username === credentials?.username && - user.password === credentials?.password - ) { + if (user.username === credentials?.username && user.password === credentials?.password) { return { id: user.username, name: user.username, email: `${user.username}@luzzle.local` } } @@ -48,14 +44,16 @@ if (config.auth.type === 'credentials' && config.auth.credentials) { ) } -const authHandle = SvelteKitAuth({ - trustHost: true, - secret: config.auth.secret, - pages: { - signIn: '/signin' - }, - providers -}) +const authHandle = auth + ? SvelteKitAuth({ + trustHost: true, + secret: auth.secret, + pages: { + signIn: '/signin' + }, + providers + }) + : undefined const PROTECTED_PREFIXES = ['/admin', '/api/admin'] @@ -63,7 +61,7 @@ const guardHandle: Handle = async ({ event, resolve }) => { const isProtected = PROTECTED_PREFIXES.some((prefix) => event.url.pathname.startsWith(prefix)) if (isProtected) { - if (!config.auth.enabled) { + if (!auth) { throw redirect(302, '/') } @@ -76,4 +74,4 @@ const guardHandle: Handle = async ({ event, resolve }) => { return resolve(event) } -export const handle = config.auth.enabled ? sequence(authHandle.handle, guardHandle) : guardHandle +export const handle = authHandle ? sequence(authHandle.handle, guardHandle) : guardHandle diff --git a/apps/web/src/lib/server/config.fixture.ts b/apps/web/src/lib/server/config.fixture.ts new file mode 100644 index 00000000..5baf9b08 --- /dev/null +++ b/apps/web/src/lib/server/config.fixture.ts @@ -0,0 +1,21 @@ +import { loadConfig, type Config } from '@luzzle/web.config' + +/** Complete resolved configuration for server tests, without reading deployment YAML. */ +export function makeConfig(overrides: Partial = {}): Config { + return { ...loadConfig(), auth: undefined, ai: undefined, ...overrides } +} + +export const credentialsAuth = { + secret: 'test-session-secret', + credentials: { username: 'admin', password: 'test-password' } +} satisfies NonNullable + +export const oidcAuth = { + secret: 'test-session-secret', + oidc: { + name: 'Single Sign-On', + issuer: 'https://issuer.example', + clientId: 'test-client', + clientSecret: 'test-client-secret' + } +} satisfies NonNullable diff --git a/apps/web/src/lib/server/generation.test.ts b/apps/web/src/lib/server/generation.test.ts index e5d9f709..c0839a1a 100644 --- a/apps/web/src/lib/server/generation.test.ts +++ b/apps/web/src/lib/server/generation.test.ts @@ -15,7 +15,7 @@ const fixture = vi.hoisted(() => { ai, config: { ai: ai as typeof ai | undefined, - pieces: [{ type: 'books', fields: { title: 'title' } }] + pieces: [{ type: 'books', fields: { title: 'title', date_consumed: 'date' } }] }, provider: { client: vi.fn(), @@ -73,7 +73,7 @@ const source = beforeEach(() => { vi.clearAllMocks() fixture.config.ai = fixture.ai - fixture.config.pieces = [{ type: 'books', fields: { title: 'title' } }] + fixture.config.pieces = [{ type: 'books', fields: { title: 'title', date_consumed: 'date' } }] Object.assign(DEFAULT_GENERATION_LIMITS, { maxFiles: 2, maxFileBytes: 50, @@ -376,6 +376,19 @@ test('unconfigured AI rejects without reading the body', async () => { expect(fixture.provider.generate).not.toHaveBeenCalled() }) +test('unsupported AI providers fail closed before consuming input or using Google', async () => { + fixture.config.ai = { ...fixture.ai } + // Deliberately bypass the validated config boundary to exercise provider dispatch. + Object.defineProperty(fixture.config.ai, 'provider', { value: 'unsupported' }) + const req = request() + await expect(post(req)).rejects.toMatchObject({ status: 503 }) + expect(req.bodyUsed).toBe(false) + expect(fixture.provider.client).not.toHaveBeenCalled() + expect(fixture.provider.generate).not.toHaveBeenCalled() + fixture.config.ai = fixture.ai + result(await events(await post())) +}) + test('reserves capacity before multipart parsing and releases it after malformed input', async () => { let stream!: ReadableStreamDefaultController const req = new Request('http://localhost/api/admin/generate', { diff --git a/apps/web/src/lib/server/generation.ts b/apps/web/src/lib/server/generation.ts index 6868f3cc..756dae2b 100644 --- a/apps/web/src/lib/server/generation.ts +++ b/apps/web/src/lib/server/generation.ts @@ -25,6 +25,7 @@ let active = 0 export async function generateResponse(request: Request): Promise { const ai = config.ai if (!ai) error(503, 'AI is not configured.') + if (ai.provider !== 'google') error(503, 'AI provider is not supported.') if (active >= MAX_CONCURRENT_GENERATIONS) error(429, 'Generation is busy. Please try again.') // Reserve before consuming multipart data; disconnected accepted work still owns its slot. diff --git a/apps/web/src/lib/server/health.test.ts b/apps/web/src/lib/server/health.test.ts index a8cd13cb..172817ed 100644 --- a/apps/web/src/lib/server/health.test.ts +++ b/apps/web/src/lib/server/health.test.ts @@ -1,19 +1,7 @@ import { describe, expect, test } from 'vitest' -import type { AppConfig } from './config.js' +import { makeConfig } from './config.fixture' import { buildHealthConfigSummary } from './health.js' -function makeConfig(overrides: Partial = {}): AppConfig { - return { - storage: { root: '/data/archive' }, - pieces: [], - auth: { enabled: true, secret: 'shh', type: 'oidc' }, - sync: {}, - worker: undefined, - ai: undefined, - ...overrides - } as AppConfig -} - describe('buildHealthConfigSummary', () => { test('reports the storage root', () => { const config = makeConfig({ storage: { root: '/data/archive' } }) @@ -62,9 +50,7 @@ describe('buildHealthConfigSummary', () => { test('reports oidc auth details without the client secret', () => { const config = makeConfig({ auth: { - enabled: true, secret: 'shh', - type: 'oidc', oidc: { name: 'okta', issuer: 'https://issuer.example', clientId: 'abc', clientSecret: 'x' } } }) @@ -82,9 +68,7 @@ describe('buildHealthConfigSummary', () => { test('reports credentials auth details without the password', () => { const config = makeConfig({ auth: { - enabled: true, secret: 'shh', - type: 'credentials', credentials: { username: 'admin', password: 'x' } } }) @@ -99,11 +83,21 @@ describe('buildHealthConfigSummary', () => { }) }) + test('absent auth reports admin disabled with no provider', () => { + expect(buildHealthConfigSummary(makeConfig()).auth).toEqual({ + enabled: false, + type: null, + issuer: null, + clientId: null + }) + }) + test('reports archive and cdn sync details', () => { const config = makeConfig({ sync: { - archive: { remote: 'r2', path: 'archive' }, - cdn: { remote: 'cf', path: 'assets', strategy: 'copy' } + ...makeConfig().sync, + archive: { remote: 'r2', path: 'archive', flags: [] }, + cdn: { remote: 'cf', path: 'assets', strategy: 'copy', flags: [] } } }) @@ -118,18 +112,21 @@ describe('buildHealthConfigSummary', () => { }) }) - test('reports unconfigured sync targets as null details', () => { - const config = makeConfig({ sync: {} }) + test('reports default empty sync targets as unconfigured', () => { + const config = makeConfig() const summary = buildHealthConfigSummary(config) - expect(summary.archiveSync).toEqual({ configured: false, remote: null, path: null }) - expect(summary.cdnSync).toEqual({ configured: false, remote: null, path: null, strategy: null }) + expect(summary.archiveSync).toEqual({ configured: false, remote: '', path: '' }) + expect(summary.cdnSync).toEqual({ configured: false, remote: '', path: '', strategy: 'sync' }) }) test('reports the worker address and queue path when configured', () => { const config = makeConfig({ - network: { internal: { worker: 'http://worker:9000' } }, + network: { + ...makeConfig().network, + internal: { ...makeConfig().network.internal, worker: 'http://worker:9000' } + }, worker: { queue: { path: './data/queue.sqlite' } } }) @@ -139,13 +136,16 @@ describe('buildHealthConfigSummary', () => { }) }) - test('reports no worker address or queue path when unconfigured', () => { - const config = makeConfig({ network: undefined, worker: undefined }) + test('reports the resolved worker defaults', () => { + const config = makeConfig() - expect(buildHealthConfigSummary(config).worker).toEqual({ address: null, queuePath: null }) + expect(buildHealthConfigSummary(config).worker).toEqual({ + address: config.network.internal.worker, + queuePath: config.worker.queue.path + }) }) - test('reports ai provider only when an api_key is present', () => { + test('reports ai provider only when ai is configured', () => { const configured = makeConfig({ ai: { provider: 'google', api_key: 'key' } }) const unconfigured = makeConfig({ ai: undefined }) diff --git a/apps/web/src/lib/server/health.ts b/apps/web/src/lib/server/health.ts index 90b3b1a6..cbccbe48 100644 --- a/apps/web/src/lib/server/health.ts +++ b/apps/web/src/lib/server/health.ts @@ -13,7 +13,7 @@ export interface PieceTypeSummary { export interface AuthSummary { enabled: boolean - type: string + type: 'credentials' | 'oidc' | null issuer: string | null clientId: string | null } @@ -50,6 +50,8 @@ function configuredEntries(obj: object | undefined): ConfigEntry[] { } export function buildHealthConfigSummary(config: AppConfig): HealthConfigSummary { + const auth = config.auth + const oidc = auth && 'oidc' in auth ? auth.oidc : undefined return { storageRoot: config.storage.root, pieceTypes: config.pieces.map((piece) => ({ @@ -58,10 +60,10 @@ export function buildHealthConfigSummary(config: AppConfig): HealthConfigSummary components: configuredEntries(piece.components) })), auth: { - enabled: config.auth.enabled, - type: config.auth.type, - issuer: config.auth.oidc?.issuer ?? null, - clientId: config.auth.oidc?.clientId ?? null + enabled: Boolean(auth), + type: auth ? ('credentials' in auth ? 'credentials' : 'oidc') : null, + issuer: oidc?.issuer ?? null, + clientId: oidc?.clientId ?? null }, archiveSync: { configured: Boolean(config.sync.archive?.remote), @@ -79,7 +81,7 @@ export function buildHealthConfigSummary(config: AppConfig): HealthConfigSummary queuePath: config.worker?.queue?.path ?? null }, ai: { - configured: Boolean(config.ai?.api_key), + configured: Boolean(config.ai), provider: config.ai?.provider ?? null } } diff --git a/apps/web/src/lib/server/health/testAiKey.test.ts b/apps/web/src/lib/server/health/ai.test.ts similarity index 55% rename from apps/web/src/lib/server/health/testAiKey.test.ts rename to apps/web/src/lib/server/health/ai.test.ts index 4a3f2a0c..dcb055a5 100644 --- a/apps/web/src/lib/server/health/testAiKey.test.ts +++ b/apps/web/src/lib/server/health/ai.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' import { validateApiKey } from '@luzzle/core' -import type { AppConfig } from '$lib/server/config' -import { validateAiKeyIfConfigured } from './testAiKey.js' +import { makeConfig } from '../config.fixture' +import { validateAiKeyIfConfigured } from './ai.js' vi.mock('@luzzle/core', () => ({ validateApiKey: vi.fn() @@ -18,7 +18,7 @@ beforeEach(() => { describe('validateAiKeyIfConfigured', () => { test('validates the configured api key', async () => { mocks.validateApiKey.mockResolvedValue({ ok: true }) - const config = { ai: { provider: 'google', api_key: 'key' } } as AppConfig + const config = makeConfig({ ai: { provider: 'google', api_key: 'key' } }) const result = await validateAiKeyIfConfigured(config) @@ -27,11 +27,21 @@ describe('validateAiKeyIfConfigured', () => { }) test('resolves not-ok without calling the api when ai is not configured', async () => { - const config = { ai: undefined } as AppConfig + const config = makeConfig() const result = await validateAiKeyIfConfigured(config) expect(mocks.validateApiKey).not.toHaveBeenCalled() expect(result).toEqual({ ok: false, reason: 'ai is not configured' }) }) + test('does not silently validate an unsupported provider as Google', async () => { + const config = makeConfig({ ai: { provider: 'google', api_key: 'key' } }) + // Deliberately bypass the validated config boundary to exercise fail-closed dispatch. + Object.defineProperty(config.ai, 'provider', { value: 'unsupported' }) + expect(await validateAiKeyIfConfigured(config)).toEqual({ + ok: false, + reason: 'AI provider is not supported' + }) + expect(mocks.validateApiKey).not.toHaveBeenCalled() + }) }) diff --git a/apps/web/src/lib/server/health/testAiKey.ts b/apps/web/src/lib/server/health/ai.ts similarity index 64% rename from apps/web/src/lib/server/health/testAiKey.ts rename to apps/web/src/lib/server/health/ai.ts index 562d3bdf..41a36705 100644 --- a/apps/web/src/lib/server/health/testAiKey.ts +++ b/apps/web/src/lib/server/health/ai.ts @@ -3,9 +3,10 @@ import type { AppConfig } from '$lib/server/config' import type { ProbeResult } from './probes.js' export async function validateAiKeyIfConfigured(config: AppConfig): Promise { - const apiKey = config.ai?.api_key - if (!apiKey) { + const ai = config.ai + if (!ai) { return { ok: false, reason: 'ai is not configured' } } - return validateApiKey(apiKey) + if (ai.provider === 'google') return validateApiKey(ai.api_key) + return { ok: false, reason: 'AI provider is not supported' } } diff --git a/apps/web/src/lib/server/health/testConnectivity.test.ts b/apps/web/src/lib/server/health/connectivity.test.ts similarity index 97% rename from apps/web/src/lib/server/health/testConnectivity.test.ts rename to apps/web/src/lib/server/health/connectivity.test.ts index e7ff5c30..01d1ae9f 100644 --- a/apps/web/src/lib/server/health/testConnectivity.test.ts +++ b/apps/web/src/lib/server/health/connectivity.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' import { getOpenWorkflow, getOpenWorkflowDb } from '$lib/server/workflow/index.js' import { getWorkflowRun } from '@luzzle/web.jobs' -import { testConnectivity } from './testConnectivity.js' +import { testConnectivity } from './connectivity.js' vi.mock('$lib/server/workflow/index.js', () => ({ getOpenWorkflow: vi.fn(), diff --git a/apps/web/src/lib/server/health/testConnectivity.ts b/apps/web/src/lib/server/health/connectivity.ts similarity index 100% rename from apps/web/src/lib/server/health/testConnectivity.ts rename to apps/web/src/lib/server/health/connectivity.ts diff --git a/apps/web/src/lib/server/load/dashboard.test.ts b/apps/web/src/lib/server/load/dashboard.test.ts index b25ad108..d48611cc 100644 --- a/apps/web/src/lib/server/load/dashboard.test.ts +++ b/apps/web/src/lib/server/load/dashboard.test.ts @@ -4,19 +4,27 @@ import { getPieceStats, getAssetStats } from '../dashboardStats.js' import { getRecentlyEditedPieces } from '../pieces.js' import { getOpenWorkflowDb } from '../workflow/index.js' import { getLatestWorkflowRun, type WorkflowRunRow } from '@luzzle/web.jobs' +import { makeConfig, credentialsAuth, oidcAuth } from '../config.fixture' import { loadDashboardPage } from './dashboard.js' vi.mock('$lib/server/database', () => ({ db: {} })) -vi.mock('$lib/server/config', () => ({ - config: { - content: { text: { title: 'luzzle' } }, - pieces: [{ type: 'article' }, { type: 'bookmark' }], - ai: { provider: 'google', api_key: 'key' }, - auth: { enabled: true, secret: 'shh', type: 'oidc' }, - sync: { archive: { remote: 'archive-remote' }, cdn: {} } +vi.mock('$lib/server/config', async () => { + const { makeConfig, oidcAuth } = await import('../config.fixture') + const defaults = makeConfig() + return { + config: makeConfig({ + content: { ...defaults.content, text: { title: 'luzzle', description: '' } }, + pieces: [ + { type: 'article', fields: { title: 'title', date_consumed: 'date' } }, + { type: 'bookmark', fields: { title: 'title', date_consumed: 'date' } } + ], + ai: { provider: 'google', api_key: 'key' }, + auth: oidcAuth, + sync: { ...defaults.sync, archive: { ...defaults.sync.archive, remote: 'archive-remote' } } + }) } -})) +}) vi.mock('../dashboardStats.js', () => ({ getPieceStats: vi.fn(), @@ -64,8 +72,12 @@ beforeEach(() => { mocks.getRecentlyEditedPieces.mockResolvedValue([]) mocks.getOpenWorkflowDb.mockReturnValue({} as never) mocks.getLatestWorkflowRun.mockReturnValue(null) - config.auth = { enabled: true, secret: 'shh', type: 'oidc' } - config.sync = { archive: { remote: 'archive-remote' }, cdn: {} } + config.auth = oidcAuth + const defaults = makeConfig() + config.sync = { + ...defaults.sync, + archive: { ...defaults.sync.archive, remote: 'archive-remote' } + } }) describe('loadDashboardPage', () => { @@ -98,8 +110,8 @@ describe('loadDashboardPage', () => { expect(result.lastPublish).toEqual(makeRun({ status: 'failed' })) }) - test('reports no auth type when auth is disabled', async () => { - config.auth = { enabled: false, secret: 'shh', type: 'credentials' } + test('reports no auth type when auth is absent', async () => { + config.auth = undefined const result = await loadDashboardPage() @@ -107,7 +119,7 @@ describe('loadDashboardPage', () => { }) test('shortens the credentials auth type to a display label', async () => { - config.auth = { enabled: true, secret: 'shh', type: 'credentials' } + config.auth = credentialsAuth const result = await loadDashboardPage() diff --git a/apps/web/src/lib/server/load/dashboard.ts b/apps/web/src/lib/server/load/dashboard.ts index e31d8a5b..dec5d194 100644 --- a/apps/web/src/lib/server/load/dashboard.ts +++ b/apps/web/src/lib/server/load/dashboard.ts @@ -1,16 +1,11 @@ import { db } from '$lib/server/database' -import { config, type AppConfig } from '$lib/server/config' +import { config } from '$lib/server/config' import { getPieceStats, getAssetStats, type PieceStats } from '../dashboardStats.js' import { getRecentlyEditedPieces } from '../pieces.js' import { getOpenWorkflowDb } from '../workflow/index.js' import { getLatestWorkflowRun, type WorkflowRunRow } from '@luzzle/web.jobs' import type { WebPieces } from '@luzzle/web.db' -const AUTH_TYPE_LABELS: Record = { - oidc: 'oidc', - credentials: 'local' -} - export interface DashboardView { meta: { title: string } pieceStats: PieceStats @@ -53,7 +48,7 @@ export async function loadDashboardPage(): Promise { setup: { pieceTypeCount: config.pieces.length, aiConfigured: Boolean(config.ai), - authType: config.auth.enabled ? AUTH_TYPE_LABELS[config.auth.type] : null, + authType: config.auth ? ('credentials' in config.auth ? 'local' : 'oidc') : null, archiveSyncConfigured: Boolean(config.sync.archive?.remote), cdnSyncConfigured: Boolean(config.sync.cdn?.remote) } diff --git a/apps/web/src/lib/server/load/health.test.ts b/apps/web/src/lib/server/load/health.test.ts index e5bce482..b4a70802 100644 --- a/apps/web/src/lib/server/load/health.test.ts +++ b/apps/web/src/lib/server/load/health.test.ts @@ -1,28 +1,17 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' import { config } from '$lib/server/config' +import { makeConfig, credentialsAuth, oidcAuth } from '../config.fixture' import { buildHealthConfigSummary } from '../health.js' +import type * as Health from '../health.js' import { probeStorage, probeWorker, probeOidcIssuer } from '../health/probes.js' import { loadHealthPage, probeWorkerIfConfigured, probeOidcIssuerIfConfigured } from './health.js' -import type { AppConfig } from '$lib/server/config' -vi.mock('$lib/server/config', () => ({ - config: { - content: { text: { title: 'luzzle' } }, - storage: { root: '/data' }, - network: { internal: { worker: 'http://worker:9000' } }, - auth: { - enabled: true, - secret: 'shh', - type: 'oidc', - oidc: { name: 'okta', issuer: 'https://issuer.example', clientId: 'x', clientSecret: 'y' } - } - } -})) - -vi.mock('../health.js', () => ({ - buildHealthConfigSummary: vi.fn() -})) +vi.mock('$lib/server/config', async () => { + const { makeConfig } = await import('../config.fixture') + return { config: makeConfig() } +}) +vi.mock('../health.js', () => ({ buildHealthConfigSummary: vi.fn() })) vi.mock('../health/probes.js', () => ({ probeStorage: vi.fn(), probeWorker: vi.fn(), @@ -36,25 +25,24 @@ const mocks = { probeOidcIssuer: vi.mocked(probeOidcIssuer) } -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks() - mocks.buildHealthConfigSummary.mockReturnValue({} as never) + const { buildHealthConfigSummary: summarize } = + await vi.importActual('../health.js') + mocks.buildHealthConfigSummary.mockReturnValue(summarize(makeConfig())) mocks.probeStorage.mockResolvedValue({ ok: true }) mocks.probeWorker.mockResolvedValue({ ok: true }) mocks.probeOidcIssuer.mockResolvedValue({ ok: true }) - config.network = { internal: { worker: 'http://worker:9000' } } - config.auth = { - enabled: true, - secret: 'shh', - type: 'oidc', - oidc: { name: 'okta', issuer: 'https://issuer.example', clientId: 'x', clientSecret: 'y' } - } + Object.assign(config, makeConfig()) + config.content.text.title = 'luzzle' + config.storage.root = '/data' + config.network.internal.worker = 'http://worker:9000' + config.auth = oidcAuth }) describe('loadHealthPage', () => { - test('awaits the storage probe but streams the worker and oidc issuer probes', async () => { + test('awaits storage but streams worker and oidc issuer probes', async () => { const result = await loadHealthPage() - expect(result.meta).toEqual({ title: 'health | luzzle' }) expect(result.storage).toEqual({ ok: true }) expect(mocks.probeStorage).toHaveBeenCalledWith('/data') @@ -64,11 +52,9 @@ describe('loadHealthPage', () => { await expect(result.oidcIssuer).resolves.toEqual({ ok: true }) }) - test('skips the worker probe when no worker address is configured', async () => { - config.network = undefined - + test('skips the worker probe when its address is empty', async () => { + config.network.internal.worker = '' const result = await loadHealthPage() - expect(mocks.probeWorker).not.toHaveBeenCalled() await expect(result.worker).resolves.toEqual({ ok: false, @@ -76,33 +62,26 @@ describe('loadHealthPage', () => { }) }) - test('skips the oidc issuer probe when auth is not oidc', async () => { - config.auth = { - enabled: true, - secret: 'shh', - type: 'credentials', - credentials: { username: 'admin', password: 'x' } + test.each([undefined, credentialsAuth])( + 'skips oidc when absent or credentials auth is selected', + async (auth) => { + config.auth = auth + const result = await loadHealthPage() + expect(mocks.probeOidcIssuer).not.toHaveBeenCalled() + await expect(result.oidcIssuer).resolves.toBeNull() } - - const result = await loadHealthPage() - - expect(mocks.probeOidcIssuer).not.toHaveBeenCalled() - await expect(result.oidcIssuer).resolves.toBeNull() - }) + ) }) describe('probeWorkerIfConfigured', () => { test('probes the configured worker address', async () => { - const appConfig = { network: { internal: { worker: 'http://worker:9000' } } } as AppConfig - - await expect(probeWorkerIfConfigured(appConfig)).resolves.toEqual({ ok: true }) + await expect(probeWorkerIfConfigured(config)).resolves.toEqual({ ok: true }) expect(mocks.probeWorker).toHaveBeenCalledWith('http://worker:9000', 5000) }) - test('resolves not-ok without probing when no worker address is configured', async () => { - const appConfig = { network: undefined } as AppConfig - - await expect(probeWorkerIfConfigured(appConfig)).resolves.toEqual({ + test('resolves not-ok without probing an empty worker address', async () => { + config.network.internal.worker = '' + await expect(probeWorkerIfConfigured(config)).resolves.toEqual({ ok: false, reason: 'worker address not configured' }) @@ -111,26 +90,18 @@ describe('probeWorkerIfConfigured', () => { }) describe('probeOidcIssuerIfConfigured', () => { - test('probes the issuer when auth type is oidc', async () => { - const appConfig = { - auth: { - enabled: true, - secret: 'shh', - type: 'oidc', - oidc: { issuer: 'https://issuer.example' } - } - } as AppConfig - - await expect(probeOidcIssuerIfConfigured(appConfig)).resolves.toEqual({ ok: true }) + test('probes the selected oidc issuer', async () => { + await expect(probeOidcIssuerIfConfigured(makeConfig({ auth: oidcAuth }))).resolves.toEqual({ + ok: true + }) expect(mocks.probeOidcIssuer).toHaveBeenCalledWith('https://issuer.example', 5000) }) - test('resolves null without probing when auth is not oidc', async () => { - const appConfig = { - auth: { enabled: true, secret: 'shh', type: 'credentials' } - } as AppConfig - - await expect(probeOidcIssuerIfConfigured(appConfig)).resolves.toBeNull() - expect(mocks.probeOidcIssuer).not.toHaveBeenCalled() - }) + test.each([undefined, credentialsAuth])( + 'resolves null for absent or credentials auth', + async (auth) => { + await expect(probeOidcIssuerIfConfigured(makeConfig({ auth }))).resolves.toBeNull() + expect(mocks.probeOidcIssuer).not.toHaveBeenCalled() + } + ) }) diff --git a/apps/web/src/lib/server/load/health.ts b/apps/web/src/lib/server/load/health.ts index dbaad85b..849cc56e 100644 --- a/apps/web/src/lib/server/load/health.ts +++ b/apps/web/src/lib/server/load/health.ts @@ -21,7 +21,7 @@ export function probeWorkerIfConfigured(appConfig: AppConfig): Promise { - const issuer = appConfig.auth.type === 'oidc' ? appConfig.auth.oidc?.issuer : undefined + const issuer = appConfig.auth && 'oidc' in appConfig.auth ? appConfig.auth.oidc.issuer : undefined return issuer ? probeOidcIssuer(issuer, PROBE_TIMEOUT_MS) : Promise.resolve(null) } diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts index 7923ca72..5a480ce9 100644 --- a/apps/web/src/routes/+layout.server.ts +++ b/apps/web/src/routes/+layout.server.ts @@ -14,7 +14,7 @@ export const load = async () => { app_assets: config.url.app_assets, luzzle_assets: config.url.luzzle_assets } - } as AppConfigPublic, + } satisfies AppConfigPublic, meta: { title: config.content.text.title, description: config.content.text.description diff --git a/apps/web/src/routes/admin/health/+page.server.ts b/apps/web/src/routes/admin/health/+page.server.ts index e2224095..72445087 100644 --- a/apps/web/src/routes/admin/health/+page.server.ts +++ b/apps/web/src/routes/admin/health/+page.server.ts @@ -1,7 +1,7 @@ import { config } from '$lib/server/config' import { probeStorage } from '$lib/server/health/probes.js' -import { testConnectivity } from '$lib/server/health/testConnectivity.js' -import { validateAiKeyIfConfigured } from '$lib/server/health/testAiKey.js' +import { testConnectivity } from '$lib/server/health/connectivity.js' +import { validateAiKeyIfConfigured } from '$lib/server/health/ai.js' import { loadHealthPage, probeWorkerIfConfigured, diff --git a/apps/web/src/routes/admin/health/+page.svelte b/apps/web/src/routes/admin/health/+page.svelte index a764a93e..9b543014 100644 --- a/apps/web/src/routes/admin/health/+page.svelte +++ b/apps/web/src/routes/admin/health/+page.svelte @@ -92,7 +92,7 @@
type
-
{data.configSummary.auth.type}
+
{data.configSummary.auth.type ?? 'none'}
{#if data.configSummary.auth.issuer}
diff --git a/apps/web/src/routes/admin/piece/[...path]/source/page.server.test.ts b/apps/web/src/routes/admin/piece/[...path]/source/page.server.test.ts index 2153533b..beff6fa8 100644 --- a/apps/web/src/routes/admin/piece/[...path]/source/page.server.test.ts +++ b/apps/web/src/routes/admin/piece/[...path]/source/page.server.test.ts @@ -12,12 +12,16 @@ import { } from '@luzzle/core' import { getPieces } from '$lib/server/pieces' import { getStorage } from '$lib/server/storage' -import { actions } from './+page.server' +import { actions, load } from './+page.server' +import { config } from '$lib/server/config' vi.mock('$lib/server/pieces', () => ({ getPieces: vi.fn(), getWebPiece: vi.fn() })) vi.mock('$lib/server/storage', () => ({ getStorage: vi.fn() })) vi.mock('$lib/server/database', () => ({ db: {} })) -vi.mock('$lib/server/config', () => ({ config: {} })) +vi.mock('$lib/server/config', async () => { + const { makeConfig } = await import('$lib/server/config.fixture') + return { config: makeConfig() } +}) vi.mock('@luzzle/core', async (importOriginal) => ({ ...(await importOriginal()), savePieceAsset: vi.fn() @@ -198,3 +202,32 @@ test.each(['https://example.test/cover.png', 'HTTPS://example.test/cover.png'])( ) } ) + +test.each([false, true])( + 'source loader exposes only generation availability (configured: %s)', + async (configured) => { + config.ai = configured ? { provider: 'google', api_key: 'private-ai-key' } : undefined + const { piece, storage } = setup('') + Object.assign(piece, { + get: vi.fn().mockResolvedValue({ filePath: 'books/example.books.md' }), + schema: { type: 'object', properties: {} } + }) + Object.assign(storage, { + readFile: vi.fn().mockResolvedValue('Unsaved source'), + stat: vi.fn().mockResolvedValue({ last_modified: new Date(0) }) + }) + const result = await load({ params: { path: 'books/example.books.md' } } as Parameters< + typeof load + >[0]) + expect(result).toEqual({ + file: 'books/example.books.md', + type: 'books', + rawContent: 'Unsaved source', + schema: { type: 'object', properties: {} }, + canGenerate: configured, + assetFields: [], + publishedAt: null, + fileUpdatedAt: 0 + }) + } +) diff --git a/apps/web/src/routes/admin/pieces/create/[[directory]]/page.server.test.ts b/apps/web/src/routes/admin/pieces/create/[[directory]]/page.server.test.ts index 02ee9067..6f038d38 100644 --- a/apps/web/src/routes/admin/pieces/create/[[directory]]/page.server.test.ts +++ b/apps/web/src/routes/admin/pieces/create/[[directory]]/page.server.test.ts @@ -13,7 +13,10 @@ import { actions, load } from './+page.server' vi.mock('$lib/server/pieces', () => ({ getPieces: vi.fn() })) vi.mock('$lib/server/config', () => ({ - config: { pieces: [{ type: 'books', fields: { title: 'title' } }], ai: {} } + config: { + pieces: [{ type: 'books', fields: { title: 'title', date_consumed: 'date' } }], + ai: { provider: 'google', api_key: 'test-key' } + } })) vi.mock('@luzzle/core', async (importOriginal) => ({ ...(await importOriginal()), diff --git a/apps/web/src/routes/api/auth/verify/+server.ts b/apps/web/src/routes/api/auth/verify/+server.ts index 8ca8ab8b..86b1a240 100644 --- a/apps/web/src/routes/api/auth/verify/+server.ts +++ b/apps/web/src/routes/api/auth/verify/+server.ts @@ -3,8 +3,8 @@ import { config } from '$lib/server/config' import type { RequestEvent } from './$types' export async function GET(event: RequestEvent) { - if (!config.auth.enabled) { - return json({ status: 'ok' }) + if (!config.auth) { + return new Response('Unauthorized', { status: 401 }) } const session = await event.locals.auth?.() diff --git a/apps/web/src/routes/api/auth/verify/server.test.ts b/apps/web/src/routes/api/auth/verify/server.test.ts new file mode 100644 index 00000000..b72efa45 --- /dev/null +++ b/apps/web/src/routes/api/auth/verify/server.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, expect, test, vi } from 'vitest' +import { config } from '$lib/server/config' +import { credentialsAuth, oidcAuth } from '$lib/server/config.fixture' +import { GET } from './+server' + +vi.mock('$lib/server/config', async () => { + const { makeConfig } = await import('$lib/server/config.fixture') + return { config: makeConfig() } +}) + +beforeEach(() => { + config.auth = credentialsAuth +}) + +function event(session: object | null) { + return { locals: { auth: vi.fn().mockResolvedValue(session) } } as unknown as Parameters< + typeof GET + >[0] +} + +test('public-only sites deny proxy authorization even if a session exists', async () => { + config.auth = undefined + const request = event({ user: { name: 'admin' } }) + const response = await GET(request) + expect(response.status).toBe(401) + expect(request.locals.auth).not.toHaveBeenCalled() +}) + +test('public-only sites deny proxy authorization without Auth.js locals', async () => { + config.auth = undefined + const response = await GET({ locals: {} } as Parameters[0]) + expect(response.status).toBe(401) +}) + +test.each([credentialsAuth, oidcAuth])('configured auth requires a session', async (auth) => { + config.auth = auth + expect((await GET(event(null))).status).toBe(401) + const response = await GET(event({ user: { name: 'admin' } })) + expect(response.status).toBe(200) + 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 new file mode 100644 index 00000000..7616bba8 --- /dev/null +++ b/apps/web/src/routes/layout.server.test.ts @@ -0,0 +1,27 @@ +import { expect, test, vi } from 'vitest' +import { load } from './+layout.server' + +vi.mock('$lib/server/config', async () => { + const { makeConfig, oidcAuth } = await import('$lib/server/config.fixture') + return { + config: makeConfig({ + auth: oidcAuth, + ai: { provider: 'google', api_key: 'private-ai-key' }, + content: { + text: { title: 'Public title', description: 'Public description' }, + component: { root: '/private/components/root.svelte' } + }, + url: { app: 'https://example.test', app_assets: '/app', luzzle_assets: '/assets' } + }) + } +}) + +test('the public layout returns only its explicit safe configuration projection', async () => { + expect(await load()).toEqual({ + config: { + content: { text: { title: 'Public title', description: 'Public description' } }, + url: { app: 'https://example.test', app_assets: '/app', luzzle_assets: '/assets' } + }, + meta: { title: 'Public title', description: 'Public description' } + }) +}) diff --git a/apps/web/src/routes/signin/+page.server.ts b/apps/web/src/routes/signin/+page.server.ts index 2033a7e6..818875c5 100644 --- a/apps/web/src/routes/signin/+page.server.ts +++ b/apps/web/src/routes/signin/+page.server.ts @@ -3,6 +3,9 @@ import type { PageServerLoad } from './$types' import { config } from '$lib/server/config' export const load: PageServerLoad = async ({ locals, url }) => { + const auth = config.auth + if (!auth) throw redirect(302, '/') + const session = await locals.auth() const redirectTo = url.searchParams.get('redirectTo') || '/admin' @@ -11,7 +14,7 @@ export const load: PageServerLoad = async ({ locals, url }) => { } return { - authType: config.auth.type, - oidcName: config.auth.oidc?.name + authType: 'credentials' in auth ? 'credentials' : 'oidc', + oidcName: 'oidc' in auth ? auth.oidc.name : undefined } } diff --git a/apps/web/src/routes/signin/page.server.test.ts b/apps/web/src/routes/signin/page.server.test.ts new file mode 100644 index 00000000..559e684f --- /dev/null +++ b/apps/web/src/routes/signin/page.server.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, expect, test, vi } from 'vitest' +import { config } from '$lib/server/config' +import { credentialsAuth, oidcAuth } from '$lib/server/config.fixture' +import { load } from './+page.server' + +vi.mock('$lib/server/config', async () => { + const { makeConfig } = await import('$lib/server/config.fixture') + return { config: makeConfig() } +}) + +beforeEach(() => { + config.auth = credentialsAuth +}) + +function event(session: object | null) { + return { + locals: { auth: vi.fn().mockResolvedValue(session) }, + url: new URL('http://localhost/signin') + } as unknown as Parameters[0] +} + +test('public-only signin redirects before accessing missing Auth.js locals', async () => { + config.auth = undefined + await expect(load({ locals: {} } as Parameters[0])).rejects.toMatchObject({ + status: 302, + location: '/' + }) +}) + +test('credentials signin projects only the selected provider', async () => { + expect(await load(event(null))).toEqual({ authType: 'credentials', oidcName: undefined }) +}) + +test('OIDC signin exposes its display name but no provider secrets', async () => { + config.auth = oidcAuth + expect(await load(event(null))).toEqual({ authType: 'oidc', oidcName: 'Single Sign-On' }) +}) + +test('authenticated signin redirects to the admin page', async () => { + await expect(load(event({ user: { name: 'admin' } }))).rejects.toMatchObject({ + status: 302, + location: '/admin' + }) +}) diff --git a/packages/web/config/README.md b/packages/web/config/README.md index ae3179d3..dcc3dd65 100644 --- a/packages/web/config/README.md +++ b/packages/web/config/README.md @@ -1,9 +1,12 @@ # @luzzle/web.config ⚙️ -Internal shared package that defines config schemas (using JSON schema / AJV) and configuration loaders for the web stack. +Shared YAML configuration loading for the Web Explorer and Worker. TypeBox +provides validation, inferred types and the generated YAML editor schema. -## Purpose 🧠 +Regenerate the editor schema: -This package is a local monorepo dependency. It centralizes config validation logic so both the Web Explorer SvelteKit application and the background Worker process validate incoming YAML settings identically. +```sh +pnpm --filter @luzzle/web.config build:external-schema +``` -It is not published to npm. +See the [configuration reference](../docs/config.md) for settings and examples. diff --git a/packages/web/config/package.json b/packages/web/config/package.json index 2baa3316..48f00544 100644 --- a/packages/web/config/package.json +++ b/packages/web/config/package.json @@ -5,13 +5,13 @@ "author": "eleith", "scripts": { "lint": "eslint src", - "build:assets": "cp -r ./src/lib/config ./dist/src/lib", - "build:external-schema": "node scripts/generate-external-schema.js > src/lib/config/web.config.schema.json", - "build:types": "json2ts --input src/lib/config/schema.json --output src/lib/config/schema.ts", - "prebuild": "npm run build:external-schema && npm run build:types", + "build:assets": "cp src/lib/config/web.config.schema.json dist/src/lib/config/", + "build:external-schema": "node scripts/generate-external-schema.js", + "check:schemas": "node scripts/generate-external-schema.js --check", + "prebuild": "npm run build:external-schema && rm -rf dist", "postbuild": "npm run build:assets", "build": "tsc -p tsconfig.build.json", - "test": "vitest run --silent --coverage" + "test": "node scripts/generate-external-schema.js --check && vitest run --silent --coverage" }, "engines": { "node": ">=24" @@ -42,7 +42,6 @@ "eslint-config-prettier": "catalog:", "eslint-plugin-prettier": "5.2.1", "globals": "catalog:", - "json-schema-to-typescript": "15.0.4", "prettier": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:", @@ -50,7 +49,7 @@ "vitest": "catalog:" }, "dependencies": { - "ajv": "8.17.1", + "@sinclair/typebox": "0.34.49", "yaml": "2.7.0" }, "type": "module" diff --git a/packages/web/config/scripts/generate-external-schema.js b/packages/web/config/scripts/generate-external-schema.js index 0cea1adf..d97d4958 100644 --- a/packages/web/config/scripts/generate-external-schema.js +++ b/packages/web/config/scripts/generate-external-schema.js @@ -1,46 +1,23 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { readFileSync, writeFileSync } from "node:fs"; +import { ConfigSchema } from "../src/lib/config/schema.ts"; +import { createInputSchema } from "../src/lib/config/input-schema.ts"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const output = new URL( + "../src/lib/config/web.config.schema.json", + import.meta.url, +); +const schema = { + $schema: "http://json-schema.org/draft-07/schema#", + ...createInputSchema(ConfigSchema), +}; +const content = JSON.stringify(schema, null, 2) + "\n"; -const schemaPath = path.resolve(__dirname, '../src/lib/config/schema.json'); - -const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8')); - -/** - * Recursively removes required fields that are present in defaults. - */ -function relax(s) { - if (!s || typeof s !== 'object') return; - - if (s.type === 'object' && s.properties) { - if (s.required) { - s.required = s.required.filter((key) => { - const prop = s.properties[key]; - // If property has a default, it's not strictly required from the user - return prop && prop.default === undefined; - }); - if (s.required.length === 0) delete s.required; - } - for (const key in s.properties) { - relax(s.properties[key]); - } - } - - // Handle nested schemas (allOf, anyOf, oneOf, then, else) - const nestedKeys = ['allOf', 'anyOf', 'oneOf', 'then', 'else']; - for (const key of nestedKeys) { - if (s[key]) { - if (Array.isArray(s[key])) { - s[key].forEach((item) => relax(item)); - } else { - relax(s[key]); - } - } +if (process.argv.includes("--check")) { + if (readFileSync(output, "utf8") !== content) { + throw new Error( + "Editor schema is stale. Run pnpm --filter @luzzle/web.config build:external-schema.", + ); } +} else { + writeFileSync(output, content); } - -relax(schema); - -console.log(JSON.stringify(schema, null, 2)); diff --git a/packages/web/config/src/index.test.ts b/packages/web/config/src/index.test.ts index 2d45bd1a..61ecad48 100644 --- a/packages/web/config/src/index.test.ts +++ b/packages/web/config/src/index.test.ts @@ -1,38 +1,102 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, expectTypeOf, test } from "vitest"; import { loadConfig, + validateConfig, getConfigValue, setConfigValue, + ConfigError, + type Config, type ConfigPublic, -} from './index.js' +} from "./index.js"; -describe('index (package entry point)', () => { - test('should export loadConfig as a function', () => { - expect(typeof loadConfig).toBe('function') - }) +describe("index (package entry point)", () => { + test("exports the loader, validator and path helpers", () => { + expect(typeof loadConfig).toBe("function"); + expect(typeof validateConfig).toBe("function"); + expect(typeof getConfigValue).toBe("function"); + expect(typeof setConfigValue).toBe("function"); + expectTypeOf(validateConfig).returns.toEqualTypeOf(); + }); - test('should export getConfigValue as a function', () => { - expect(typeof getConfigValue).toBe('function') - }) + test("exports ConfigError with structured validation issues", () => { + try { + validateConfig({ storage: { root: "" } }, {}); + throw new Error("Expected validation to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + if (!(error instanceof ConfigError)) throw error; + expect(error.issues).toContainEqual({ + path: "/storage/root", + message: expect.any(String), + }); + } + }); - test('should export setConfigValue as a function', () => { - expect(typeof setConfigValue).toBe('function') - }) + test("exports resolved types with required defaulted sections and optional services", () => { + const config: Config = validateConfig({}, {}); + expect(config.auth).toBeUndefined(); + expect(config.ai).toBeUndefined(); + expectTypeOf(config.network.internal.explorer).toEqualTypeOf(); + expectTypeOf(config.network.public.host).toEqualTypeOf(); + expectTypeOf(config.network.public.hmr_port).toEqualTypeOf< + number | undefined + >(); + expectTypeOf(config.sync.archive.remote).toEqualTypeOf(); + expectTypeOf(config.sync.cdn.flags).toEqualTypeOf(); + expectTypeOf(config.sync.cdn.strategy).toEqualTypeOf<"sync" | "copy">(); + expectTypeOf(config.worker.queue.path).toEqualTypeOf(); + expectTypeOf(config.theme.light["color-primary"]).toEqualTypeOf(); + expectTypeOf( + config.theme.globals["font-size-root"], + ).toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + { provider: "google"; api_key: string } | undefined + >(); + expectTypeOf().toEqualTypeOf< + | { secret: string; credentials: { username: string; password: string } } + | { + secret: string; + oidc: { + name: string; + issuer: string; + clientId: string; + clientSecret: string; + }; + } + | undefined + >(); + }); - test('should export Config type (compile-time check)', () => { - const config = loadConfig() - expect(config.url).toBeDefined() - expect(config.auth).toBeDefined() - expect(config.storage).toBeDefined() - }) - - test('should export ConfigPublic type (compile-time check)', () => { - const config = loadConfig() + test("keeps ConfigPublic an explicit projection without sensitive sections", () => { + const config = validateConfig( + { + auth: { + secret: "private-secret", + credentials: { username: "admin", password: "private-password" }, + }, + ai: { provider: "google", api_key: "private-key" }, + }, + {}, + ); const publicConfig: ConfigPublic = { url: config.url, content: config.content, - } - expect(publicConfig.url.app).toBeDefined() - expect(publicConfig.content.text.title).toBeDefined() - }) -}) + }; + expect(publicConfig.url.app).toBe("http://localhost:8080"); + expect(publicConfig.content.text.title).toBe("Luzzle Explorer"); + expectTypeOf().toEqualTypeOf<"url" | "content">(); + expectTypeOf().toEqualTypeOf< + "app" | "app_assets" | "luzzle_assets" + >(); + // @ts-expect-error Session secrets and provider credentials are not public. + expect(publicConfig.auth).toBeUndefined(); + // @ts-expect-error AI API keys are not public. + expect(publicConfig.ai).toBeUndefined(); + // @ts-expect-error Asset salts are not public. + expect(publicConfig.assets).toBeUndefined(); + // @ts-expect-error Local storage configuration is not public. + expect(publicConfig.storage).toBeUndefined(); + // @ts-expect-error Internal network addresses are not public. + expect(publicConfig.network).toBeUndefined(); + }); +}); diff --git a/packages/web/config/src/index.ts b/packages/web/config/src/index.ts index 749e83aa..b78ee8d3 100644 --- a/packages/web/config/src/index.ts +++ b/packages/web/config/src/index.ts @@ -1,14 +1,18 @@ import type { Config, ConfigPublic } from "./lib/config/config.js"; import { loadConfig, + validateConfig, getConfigValue, setConfigValue, } from "./lib/config/config.js"; +export { ConfigError } from "./lib/config/errors.js"; +export type { ConfigIssue } from "./lib/config/errors.js"; export { type Config, type ConfigPublic, loadConfig, + validateConfig, getConfigValue, setConfigValue, -}; \ No newline at end of file +}; diff --git a/packages/web/config/src/lib/config/config.test.ts b/packages/web/config/src/lib/config/config.test.ts index bf386ff9..6ab94cdf 100644 --- a/packages/web/config/src/lib/config/config.test.ts +++ b/packages/web/config/src/lib/config/config.test.ts @@ -1,783 +1,726 @@ -import { describe, expect, test, vi, afterEach } from 'vitest' -import type { Config, ConfigPublic } from './config.js'; -import { loadConfig, getConfigValue, setConfigValue } from './config.js' -import { writeFileSync, unlinkSync } from 'fs' -import { join } from 'path' -import { tmpdir } from 'os' +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { Config } from "./config.js"; +import { + loadConfig, + validateConfig, + getConfigValue, + setConfigValue, +} from "./config.js"; +import { ConfigError } from "./errors.js"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; interface TestConfig extends Config { - a: { - b: { - c: string - } - } + a: { b: { c: string } }; } -describe('lib/config/config', () => { - test('should validate default config against schema', async () => { - try { - const config = loadConfig() - expect(config).toBeDefined() - } catch (e) { - if (e) { - expect(e).toBeUndefined() - } - } - }) +function validationError( + raw: unknown, + env: NodeJS.ProcessEnv = {}, +): ConfigError { + try { + validateConfig(raw, env); + } catch (error) { + if (error instanceof ConfigError) return error; + throw error; + } + throw new Error("Expected configuration validation to fail"); +} - test('ConfigPublic should not expose sensitive fields', () => { - // This is a compile-time check primarily, but we can verify at runtime - // that the type definition excludes it. - const config = loadConfig() - const publicConfig: ConfigPublic = { - url: config.url, - content: config.content - } - - // @ts-expect-error - assets should not exist on ConfigPublic - expect(publicConfig.assets).toBeUndefined() - // @ts-expect-error - auth should not exist on ConfigPublic - expect(publicConfig.auth).toBeUndefined() - // @ts-expect-error - storage should not exist on ConfigPublic - expect(publicConfig.storage).toBeUndefined() - }) +describe("loadConfig", () => { + let directory: string; + let filename: string; - test('should load a user config', () => { - const config = loadConfig(`${import.meta.dirname}/user.config.yaml`) - expect(config).toBeDefined() - }) + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "luzzle-config-")); + filename = join(directory, "config.yaml"); + vi.stubEnv("LUZZLE_APP_URL", undefined); + vi.stubEnv("LUZZLE_ASSET_SALT", undefined); + }); - test('should load a user config', () => { - const config = loadConfig(`${import.meta.dirname}/not.user.config.yaml`) - expect(config).toBeDefined() - }) + afterEach(() => { + rmSync(directory, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); - test('should handle an empty config file', () => { - const tmpConfigPath = join(tmpdir(), `empty-config-${Date.now()}.yaml`) - writeFileSync(tmpConfigPath, '') - const config = loadConfig(tmpConfigPath) - expect(config.url.app).toBe('http://localhost:8080') - unlinkSync(tmpConfigPath) - }) + test("loads defaults without a filename", () => { + const config = loadConfig(); + expect(config.url.app).toBe("http://localhost:8080"); + expect(config.paths.config).toBeUndefined(); + expect(config.auth).toBeUndefined(); + }); - test('should throw an error if config validation fails with user config', () => { expect(() => loadConfig(`${import.meta.dirname}/user-error.config.yaml`)).toThrow( - 'Configuration validation failed' - ) - }) + test("loads a user config fixture", () => { + const config = loadConfig(`${import.meta.dirname}/user.config.yaml`); + expect(config.url.app).toBe("https://example.com"); + expect(config.storage.root).toBe("./archive"); + }); - describe('getConfigValue', () => { - test('should return the correct value for a given path', () => { - const config = { - a: { - b: { - c: 'value' - } - } - } as unknown as TestConfig - const value = getConfigValue(config, 'a.b.c') - expect(value).toBe('value') - }) + test("loads defaults for a missing file and records its path", () => { + const config = loadConfig(filename); + expect(config.url.app).toBe("http://localhost:8080"); + expect(config.paths.config).toBe(filename); + }); - test('should return undefined for a non-existent path', () => { - const config = { - a: { - b: { - c: 'value' - } - } - } as unknown as TestConfig - const value = getConfigValue(config, 'a.b.d') - expect(value).toBeUndefined() - }) + test.each(["", "# comments only\n", "null\n", "~\n"])( + "loads defaults for empty/null YAML %j", + (yaml) => { + writeFileSync(filename, yaml); + const config = loadConfig(filename); + expect(config.url.app).toBe("http://localhost:8080"); + expect(config.paths.config).toBe(filename); + }, + ); - test('should return the value for a root-level key', () => { - const config = { - key: 'value' - } as unknown as TestConfig - const value = getConfigValue(config, 'key') - expect(value).toBe('value') - }) + test.each(["false", "0", "42", "a scalar", '""', "[]", "- url: {}"])( + "rejects non-object YAML %j", + (yaml) => { + writeFileSync(filename, yaml); + expect(() => loadConfig(filename)).toThrow(ConfigError); + }, + ); - test('should return an object for a path to a non-leaf', () => { - const config = { - a: { - b: { - c: 'value' - } - } - } as unknown as TestConfig - const value = getConfigValue(config, 'a.b') - expect(value).toEqual({ c: 'value' }) - }) + test("rejects invalid YAML syntax", () => { + writeFileSync(filename, "url: ["); + expect(() => loadConfig(filename)).toThrow(); + }); - test('should return undefined for a path starting with a non-existent key', () => { - const config = { - a: { - b: 'value' - } - } as unknown as TestConfig - const value = getConfigValue(config, 'x.y.z') - expect(value).toBeUndefined() - }) + test("rejects unknown properties in a user config fixture", () => { + expect(() => + loadConfig(`${import.meta.dirname}/user-error.config.yaml`), + ).toThrow(ConfigError); + }); - test('should return undefined for an empty config', () => { - const config = {} as unknown as TestConfig - const value = getConfigValue(config, 'a.b') - expect(value).toBeUndefined() - }) - }) - - describe('setConfigValue', () => { - test('should correctly set a value at a given path', () => { - const config = { - a: { - b: { - c: 'value' - } - } - } as unknown as TestConfig - setConfigValue(config, 'a.b.c', 'new-value') - expect(config.a.b.c).toBe('new-value') - }) - - test('should create intermediate objects if they don\'t exist', () => { - const config = { - a: {} - } as unknown as TestConfig - setConfigValue(config, 'a.b.c', 'value') - expect(config.a.b.c).toBe('value') - }) - - test('should set a value at a root-level key', () => { - const config = {} as unknown as Record - setConfigValue(config as unknown as TestConfig, 'key', 'value') - expect(config.key).toBe('value') - }) - - test('should overwrite non-object intermediate when setting a nested path', () => { - const config = { - a: { - b: 'not-an-object' - } - } as unknown as TestConfig - setConfigValue(config, 'a.b.c', 'value') - expect((config as unknown as { a: { b: { c: string } } }).a.b.c).toBe('value') - }) - - test('should set a value to null', () => { - const config = { - a: { - b: { - c: 'value' - } - } - } as unknown as TestConfig - setConfigValue(config, 'a.b.c', null) - expect(config.a.b.c).toBeNull() - }) - - test('should set a value to a number', () => { - const config = { - a: { - b: { - c: 'value' - } - } - } as unknown as TestConfig - setConfigValue(config, 'a.b.c', 42) - expect(config.a.b.c).toBe(42) - }) - - test('should create deeply nested objects from scratch', () => { - const config = {} as unknown as TestConfig - setConfigValue(config, 'a.b.c.d.e', 'deep') - expect((config as unknown as { a: { b: { c: { d: { e: string } } } } }).a.b.c.d.e).toBe('deep') - }) - }) - - describe('Config Environment Substitution', () => { - const tmpConfigPath = join(tmpdir(), `test-config-${Date.now()}.yaml`) - - afterEach(() => { - try { - unlinkSync(tmpConfigPath) - } catch { - // ignore if file doesn't exist - } - vi.unstubAllEnvs() - vi.restoreAllMocks() - }) - - test('should substitute environment variables', () => { - vi.stubEnv('TEST_VAR', 'substituted_value') - const yamlContent = ` -url: - app: '' - app_assets: '' - luzzle_assets: '' -auth: - enabled: true - secret: '\${TEST_VAR}' - type: oidc - oidc: - issuer: 'https://example.com' - clientId: 'client' - clientSecret: 'secret' -` - writeFileSync(tmpConfigPath, yamlContent) - - const config = loadConfig(tmpConfigPath) - expect(config.auth.secret).toBe('substituted_value') - }) - - test('should substitute multiple environment variables in one string', () => { - vi.stubEnv('HOST', 'localhost') - vi.stubEnv('PORT', '8080') - const yamlContent = ` -url: - app: 'http://\${HOST}:\${PORT}' - app_assets: '' - luzzle_assets: '' -auth: - enabled: false - secret: 'secret' - type: oidc - oidc: - issuer: 'https://example.com' - clientId: 'client' - clientSecret: 'secret' -` - writeFileSync(tmpConfigPath, yamlContent) - - const config = loadConfig(tmpConfigPath) - expect(config.url.app).toBe('http://localhost:8080') - }) - - test('should warn and preserve string if environment variable is missing', () => { - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const yamlContent = ` -auth: - enabled: true - secret: '\${MISSING_VAR}' - type: oidc - oidc: - issuer: 'https://example.com' - clientId: 'client' - clientSecret: 'secret' -` - writeFileSync(tmpConfigPath, yamlContent) - - const config = loadConfig(tmpConfigPath) - expect(config.auth.secret).toBe('${MISSING_VAR}') - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Environment variable "MISSING_VAR" is missing')) - }) - - test('should handle escaping with $$', () => { - vi.stubEnv('TEST_VAR', 'should_not_be_used') - const yamlContent = ` -auth: - enabled: true - secret: '$\${TEST_VAR}' - type: oidc - oidc: - issuer: 'https://example.com' - clientId: 'client' - clientSecret: 'secret' -` - writeFileSync(tmpConfigPath, yamlContent) - - const config = loadConfig(tmpConfigPath) - expect(config.auth.secret).toBe('${TEST_VAR}') - }) - - test('should handle escaping with $$ in the middle of a string', () => { - const yamlContent = ` -auth: - enabled: true - secret: 'Value: $\${TEST_VAR}' - type: oidc - oidc: - issuer: 'https://example.com' - clientId: 'client' - clientSecret: 'secret' -` - writeFileSync(tmpConfigPath, yamlContent) - - const config = loadConfig(tmpConfigPath) - expect(config.auth.secret).toBe('Value: ${TEST_VAR}') - }) - - test('should use default value if environment variable is missing', () => { - const yamlContent = ` -url: - app: '\${MISSING_VAR:-http://localhost:8080}' - app_assets: '' - luzzle_assets: '' -auth: - enabled: false - secret: 'secret' - type: oidc - oidc: - issuer: 'https://example.com' - clientId: 'client' - clientSecret: 'secret' -` - writeFileSync(tmpConfigPath, yamlContent) - - const config = loadConfig(tmpConfigPath) - expect(config.url.app).toBe('http://localhost:8080') - }) - - test('should handle nested objects and arrays', () => { - vi.stubEnv('VAR_1', 'val1') - vi.stubEnv('VAR_2', 'val2') - const yamlContent = ` -storage: - root: '\${VAR_1}' -pieces: - - type: 'book' - fields: - title: '\${VAR_2}' - date_consumed: '2023-01-01' -` - writeFileSync(tmpConfigPath, yamlContent) - - const config = loadConfig(tmpConfigPath) - expect(config.storage.root).toBe('val1') - expect(config.pieces[0].fields.title).toBe('val2') - }) - - test('should handle ai configuration substitution', () => { - vi.stubEnv('AI_KEY', 'google-key') - const yamlContent = ` -ai: - provider: 'google' - api_key: '\${AI_KEY}' -` - writeFileSync(tmpConfigPath, yamlContent) - - const config = loadConfig(tmpConfigPath) - expect(config.ai?.api_key).toBe('google-key') - }) - - test('should throw error if auth is enabled but secret is empty', () => { - const yamlContent = ` -auth: - enabled: true - secret: '\${MISSING_SECRET:-}' - type: oidc - oidc: - issuer: 'https://example.com' - clientId: 'client' - clientSecret: 'secret' -` - writeFileSync(tmpConfigPath, yamlContent) - - expect(() => loadConfig(tmpConfigPath)).toThrow('Configuration validation failed') - }) - - test('should throw error if auth type is credentials but they are empty', () => { - const yamlContent = ` -auth: - enabled: true - secret: 'some-secret' - type: credentials - credentials: - username: '\${MISSING_USER:-}' - password: 'password' -` - writeFileSync(tmpConfigPath, yamlContent) - - expect(() => loadConfig(tmpConfigPath)).toThrow('Configuration validation failed') - }) - }) - - describe('Config paths tracking', () => { - const tmpConfigPath = join(tmpdir(), `paths-config-${Date.now()}.yaml`) - - afterEach(() => { - try { - unlinkSync(tmpConfigPath) - } catch { - // ignore - } - }) - - test('should set paths.config when userConfigPath is provided', () => { - const yamlContent = ` + test("loads nested overrides, provider defaults and multiple pieces from YAML", () => { + writeFileSync( + filename, + ` url: app: 'https://example.com' -` - writeFileSync(tmpConfigPath, yamlContent) - const config = loadConfig(tmpConfigPath) - expect(config.paths.config).toBe(tmpConfigPath) - }) - - test('should not set paths.config when no userConfigPath is provided', () => { - const config = loadConfig() - expect(config.paths.config).toBeUndefined() - }) - }) - - describe('Default values verification', () => { - test('should set correct default values for content.text', () => { - const config = loadConfig() - expect(config.content.text.title).toBe('Luzzle Explorer') - expect(config.content.text.description).toBe('A Luzzle Explorer instance') - }) - - test('should set correct default for storage.root', () => { - const config = loadConfig() - expect(config.storage.root).toBe('./archive') - }) - - test('should set correct default for paths.database', () => { - const config = loadConfig() - expect(config.paths.database).toBe('./data/luzzle.sqlite') - }) - - test('should set correct default for paths.assets', () => { - const config = loadConfig() - expect(config.paths.assets).toBe('./assets/pieces') - }) - - test('should set correct default for paths.cache', () => { - const config = loadConfig() - expect(config.paths.cache).toBe('./nginx') - }) - - test('should set correct default for auth', () => { - const config = loadConfig() - expect(config.auth.enabled).toBe(false) - expect(config.auth.type).toBe('oidc') - }) - - test('should set correct default for assets.salt', () => { - const config = loadConfig() - expect(config.assets.salt).toBe('') - }) - - test('should set correct default theme values', () => { - const config = loadConfig() - expect(config.theme.light['color-primary']).toBe('#0d6efd') - expect(config.theme.dark['color-primary']).toBe('#3b82f6') - expect(config.theme.globals['font-size-root']).toBe(22) - expect(config.theme.markdown.code.light).toBe('github-light') - expect(config.theme.markdown.code.dark).toBe('github-dark') - }) - }) - - describe('Environment substitution edge cases', () => { - const tmpConfigPath = join(tmpdir(), `env-edge-config-${Date.now()}.yaml`) - - afterEach(() => { - try { - unlinkSync(tmpConfigPath) - } catch { - // ignore - } - vi.unstubAllEnvs() - vi.restoreAllMocks() - }) - - test('should handle empty string env var value', () => { - vi.stubEnv('EMPTY_VAR', '') - const yamlContent = ` -url: - app: '\${EMPTY_VAR}' - app_assets: '' - luzzle_assets: '' auth: - enabled: false - secret: 'secret' - type: oidc - oidc: - issuer: 'https://example.com' - clientId: 'client' - clientSecret: 'secret' -` - writeFileSync(tmpConfigPath, yamlContent) - const config = loadConfig(tmpConfigPath) - expect(config.url.app).toBe('') - }) - - test('should handle default value with colons (URL-like)', () => { - const yamlContent = ` -url: - app: '\${MISSING_URL:-http://localhost:8080/path?query=1}' - app_assets: '' - luzzle_assets: '' -auth: - enabled: false - secret: 'secret' - type: oidc - oidc: - issuer: 'https://example.com' - clientId: 'client' - clientSecret: 'secret' -` - writeFileSync(tmpConfigPath, yamlContent) - const config = loadConfig(tmpConfigPath) - expect(config.url.app).toBe('http://localhost:8080/path?query=1') - }) - - test('should leave strings without env vars unchanged', () => { - const yamlContent = ` -url: - app: 'just-a-plain-string' - app_assets: '' - luzzle_assets: '' -auth: - enabled: false - secret: 'secret' - type: oidc - oidc: - issuer: 'https://example.com' - clientId: 'client' - clientSecret: 'secret' -` - writeFileSync(tmpConfigPath, yamlContent) - const config = loadConfig(tmpConfigPath) - expect(config.url.app).toBe('just-a-plain-string') - }) - - test('should substitute env var in nested object within array', () => { - vi.stubEnv('PIECE_FIELD_TITLE', 'My Title') - const yamlContent = ` -pieces: - - type: 'book' - fields: - title: '\${PIECE_FIELD_TITLE}' - date_consumed: '2023-01-01' -` - writeFileSync(tmpConfigPath, yamlContent) - const config = loadConfig(tmpConfigPath) - expect(config.pieces[0].fields.title).toBe('My Title') - }) - - test('should substitute same env var in multiple places', () => { - vi.stubEnv('SHARED_VAR', 'shared') - const yamlContent = ` -url: - app: '\${SHARED_VAR}' - app_assets: '\${SHARED_VAR}' - luzzle_assets: '\${SHARED_VAR}' -auth: - enabled: false - secret: 'secret' - type: oidc - oidc: - issuer: 'https://example.com' - clientId: 'client' - clientSecret: 'secret' -` - writeFileSync(tmpConfigPath, yamlContent) - const config = loadConfig(tmpConfigPath) - expect(config.url.app).toBe('shared') - expect(config.url.app_assets).toBe('shared') - expect(config.url.luzzle_assets).toBe('shared') - }) - - test('should not substitute env vars in non-string values', () => { - vi.stubEnv('APP_URL', 'https://example.com') - const yamlContent = ` -url: - app: '\${APP_URL}' - app_assets: '' - luzzle_assets: '' -` - writeFileSync(tmpConfigPath, yamlContent) - const config = loadConfig(tmpConfigPath) - expect(config.url.app).toBe('https://example.com') - expect(config.auth.enabled).toBe(false) - expect(config.theme.globals['font-size-root']).toBe(22) - }) - }) - - describe('Schema validation errors', () => { - const tmpConfigPath = join(tmpdir(), `schema-error-config-${Date.now()}.yaml`) - - afterEach(() => { - try { - unlinkSync(tmpConfigPath) - } catch { - // ignore - } - }) - - test('should throw for invalid auth type', () => { - const yamlContent = ` -url: - app: 'https://example.com' - app_assets: '' - luzzle_assets: '' -auth: - enabled: false - secret: 'secret' - type: invalid_type -` - writeFileSync(tmpConfigPath, yamlContent) - expect(() => loadConfig(tmpConfigPath)).toThrow('Configuration validation failed') - }) - - test('should throw for storage.root with empty string', () => { - const yamlContent = ` -storage: - root: '' -` - writeFileSync(tmpConfigPath, yamlContent) - expect(() => loadConfig(tmpConfigPath)).toThrow('Configuration validation failed') - }) - - test('should throw for ai.api_key with empty string when ai is present', () => { - const yamlContent = ` -ai: - provider: google - api_key: '' -` - writeFileSync(tmpConfigPath, yamlContent) - expect(() => loadConfig(tmpConfigPath)).toThrow('Configuration validation failed') - }) - - test('should throw for invalid theme code enum value', () => { - const yamlContent = ` -theme: - markdown: - code: - light: invalid_theme_name - dark: github-dark -` - writeFileSync(tmpConfigPath, yamlContent) - expect(() => loadConfig(tmpConfigPath)).toThrow('Configuration validation failed') - }) - }) - - describe('Complex config loading', () => { - const tmpConfigPath = join(tmpdir(), `complex-config-${Date.now()}.yaml`) - - afterEach(() => { - try { - unlinkSync(tmpConfigPath) - } catch { - // ignore - } - }) - - test('should load config with multiple pieces', () => { - const yamlContent = ` -pieces: - - type: 'book' - fields: - title: 'Book One' - date_consumed: '2023-01-01' - - type: 'video' - fields: - title: 'Video One' - date_consumed: '2023-02-01' - media: - - image -` - writeFileSync(tmpConfigPath, yamlContent) - const config = loadConfig(tmpConfigPath) - expect(config.pieces).toHaveLength(2) - expect(config.pieces[0].type).toBe('book') - expect(config.pieces[1].type).toBe('video') - expect(config.pieces[1].fields.media).toEqual(['image']) - }) - - test('should load config with all optional sections', () => { - const yamlContent = ` -url: - app: 'https://example.com' - app_assets: 'https://assets.example.com' - luzzle_assets: 'https://luzzle.example.com' -auth: - enabled: true - secret: 'super-secret-key' - type: oidc + secret: 'short-secret' oidc: issuer: 'https://auth.example.com' - clientId: 'my-client' - clientSecret: 'my-client-secret' + clientId: 'client' + clientSecret: 'client-secret' sync: config: '/custom/rclone.conf' archive: - remote: 's3://archive-bucket' + remote: 'archive-remote' path: '/archive/path' cdn: - remote: 's3://cdn-bucket' + remote: 'cdn-remote' path: '/cdn/path' -` - writeFileSync(tmpConfigPath, yamlContent) - const config = loadConfig(tmpConfigPath) - expect(config.url.app).toBe('https://example.com') - expect(config.auth.secret).toBe('super-secret-key') - expect(config.auth.oidc?.name).toBe('Single Sign-On') - expect(config.auth.oidc?.issuer).toBe('https://auth.example.com') - expect(config.sync.config).toBe('/custom/rclone.conf') - expect(config.sync.archive?.remote).toBe('s3://archive-bucket') - expect(config.sync.archive?.path).toBe('/archive/path') - expect(config.sync.cdn?.remote).toBe('s3://cdn-bucket') - expect(config.sync.cdn?.path).toBe('/cdn/path') - }) - - test('should allow custom oidc.name', () => { - const yamlContent = ` -url: - app: 'https://example.com' - app_assets: '' - luzzle_assets: '' -auth: - enabled: true - secret: 'secret' - type: oidc - oidc: - name: 'Okta SSO' - issuer: 'https://auth.example.com' - clientId: 'client' - clientSecret: 'secret' -` - writeFileSync(tmpConfigPath, yamlContent) - const config = loadConfig(tmpConfigPath) - expect(config.auth.oidc?.name).toBe('Okta SSO') - }) - }) - - describe('Sync Configuration', () => { - test('should default sync.archive and sync.cdn when absent', () => { - const config = loadConfig() - expect(config.sync.archive?.remote).toBe('') - expect(config.sync.archive?.path).toBe('') - expect(config.sync.cdn?.remote).toBe('') - expect(config.sync.cdn?.path).toBe('') - }) - }) - - describe('Worker Configuration', () => { - const tmpConfigPath = join(tmpdir(), `worker-config-${Date.now()}.yaml`) - - afterEach(() => { - try { - unlinkSync(tmpConfigPath) - } catch { - // ignore - } - }) - - test('should default worker.queue.path when absent', () => { - const config = loadConfig() - expect(config.worker?.queue?.path).toBe('./data/sidequest.sqlite') - }) - - test('should accept a custom worker.queue.path', () => { - const yamlContent = ` worker: queue: path: '/custom/sidequest.db' -` - writeFileSync(tmpConfigPath, yamlContent) - const config = loadConfig(tmpConfigPath) - expect(config.worker?.queue?.path).toBe('/custom/sidequest.db') - }) - }) -}) +pieces: + - type: book + fields: + title: title + date_consumed: date_read + - type: video + fields: + title: title + date_consumed: date_watched + media: [image] +`, + ); + const config = loadConfig(filename); + expect(config.url.app).toBe("https://example.com"); + expect(config.auth).toEqual({ + secret: "short-secret", + oidc: { + name: "Single Sign-On", + issuer: "https://auth.example.com", + clientId: "client", + clientSecret: "client-secret", + }, + }); + expect(config.sync).toEqual({ + config: "/custom/rclone.conf", + archive: { remote: "archive-remote", path: "/archive/path", flags: [] }, + cdn: { + remote: "cdn-remote", + path: "/cdn/path", + flags: [], + strategy: "sync", + }, + }); + expect(config.worker.queue.path).toBe("/custom/sidequest.db"); + expect(config.pieces).toHaveLength(2); + expect(config.pieces[0].type).toBe("book"); + expect(config.pieces[1].fields.media).toEqual(["image"]); + expect(config.paths.config).toBe(filename); + }); + + test("interpolates parsed YAML using process.env, including secrets and array entries", () => { + vi.stubEnv("CONFIG_TEST_SECRET", "test-secret"); + vi.stubEnv("CONFIG_TEST_TITLE", "title: # remains a string"); + vi.stubEnv("CONFIG_TEST_MEDIA", "cover"); + vi.stubEnv("CONFIG_TEST_AI_KEY", "google-key"); + // The public validator, like the filesystem loader, defaults to process.env. + expect( + validateConfig({ + ai: { provider: "google", api_key: "${CONFIG_TEST_AI_KEY}" }, + }).ai?.api_key, + ).toBe("google-key"); + writeFileSync( + filename, + ` +auth: + secret: '\${CONFIG_TEST_SECRET}' + credentials: + username: admin + password: password +ai: + provider: google + api_key: '\${CONFIG_TEST_AI_KEY}' +pieces: + - type: book + fields: + title: '\${CONFIG_TEST_TITLE}' + date_consumed: date_read + media: ['\${CONFIG_TEST_MEDIA}'] +`, + ); + const config = loadConfig(filename); + expect(config.auth?.secret).toBe("test-secret"); + expect(config.ai?.api_key).toBe("google-key"); + expect(config.pieces[0].fields.title).toBe("title: # remains a string"); + expect(config.pieces[0].fields.media).toEqual(["cover"]); + }); + + test("reports unresolved references from files with their field paths", () => { + vi.stubEnv("CONFIG_TEST_MISSING", undefined); + writeFileSync(filename, "storage:\n root: '${CONFIG_TEST_MISSING}'\n"); + expect(() => loadConfig(filename)).toThrow( + '/storage/root: Environment variable "CONFIG_TEST_MISSING" is missing.', + ); + }); +}); + +describe("validateConfig", () => { + const credentials = { username: "admin", password: "password" }; + const oidc = { + issuer: "https://auth.example.com", + clientId: "client", + clientSecret: "secret", + }; + + test("resolves defaults while leaving optional services absent", () => { + const config = validateConfig({}, {}); + expect(config.auth).toBeUndefined(); + expect(config.ai).toBeUndefined(); + expect(config.url).toEqual({ + app: "http://localhost:8080", + app_assets: "", + luzzle_assets: "", + }); + expect(config.network).toEqual({ + internal: { + explorer: "http://luzzle-web:3000", + lsp: "http://luzzle-lsp:9001", + worker: "http://luzzle-worker:9000", + }, + public: { host: "0.0.0.0" }, + }); + expect(config.content.text).toEqual({ + title: "Luzzle Explorer", + description: "A Luzzle Explorer instance", + }); + expect(config.storage.root).toBe("./archive"); + expect(config.paths).toEqual({ + database: "./data/luzzle.sqlite", + assets: "./assets/pieces", + cache: "./nginx", + static: "./static", + }); + expect(config.assets.salt).toBe(""); + expect(config.sync).toEqual({ + config: "/app/rclone/rclone.conf", + archive: { remote: "", path: "", flags: [] }, + cdn: { remote: "", path: "", flags: [], strategy: "sync" }, + }); + expect(config.worker.queue.path).toBe("./data/sidequest.sqlite"); + expect(config.pieces).toEqual([]); + expect(config.theme.light["color-primary"]).toBe("#0d6efd"); + expect(config.theme.dark["color-primary"]).toBe("#3b82f6"); + expect(config.theme.globals["font-size-root"]).toBe(22); + expect(config.theme.markdown.code).toEqual({ + light: "github-light", + dark: "github-dark", + }); + }); + + test("defaults partial nested sections without replacing explicit empty strings or zero", () => { + const config = validateConfig( + { + url: { app: "" }, + network: { public: { hmr_port: 0 } }, + sync: { + archive: { remote: "backup" }, + cdn: { strategy: "copy", flags: ["--fast-list"] }, + }, + theme: { + light: { "color-primary": "rebeccapurple" }, + globals: { "font-size-root": 0 }, + }, + }, + {}, + ); + expect(config.url.app).toBe(""); + expect(config.network.public).toEqual({ host: "0.0.0.0", hmr_port: 0 }); + expect(config.sync.archive).toEqual({ + remote: "backup", + path: "", + flags: [], + }); + expect(config.sync.cdn).toEqual({ + remote: "", + path: "", + flags: ["--fast-list"], + strategy: "copy", + }); + expect(config.theme.light["color-primary"]).toBe("rebeccapurple"); + expect(config.theme.light["color-on-primary"]).toBe("#ffffff"); + expect(config.theme.globals["font-size-root"]).toBe(0); + expect(config.theme.markdown.code.dark).toBe("github-dark"); + }); + + test("does not mutate input, environment, or share mutable defaults between calls", () => { + const raw = { + auth: { secret: "${SECRET}", oidc: { ...oidc } }, + sync: { archive: { flags: ["${FLAG}"] } }, + theme: { light: { "color-primary": "red" } }, + }; + const original = structuredClone(raw); + const env = { SECRET: "resolved-secret", FLAG: "--fast-list" }; + const config = validateConfig(raw, env); + expect(raw).toEqual(original); + expect(env).toEqual({ SECRET: "resolved-secret", FLAG: "--fast-list" }); + config.sync.archive.flags.push("--verbose"); + config.theme.light["color-primary"] = "blue"; + config.theme.dark["color-primary"] = "green"; + config.pieces.push({ + type: "book", + fields: { title: "title", date_consumed: "date" }, + }); + expect(raw).toEqual(original); + const next = validateConfig(raw, env); + expect(next.sync.archive.flags).toEqual(["--fast-list"]); + expect(next.theme.light["color-primary"]).toBe("red"); + expect(next.theme.dark["color-primary"]).toBe("#3b82f6"); + expect(next.pieces).toEqual([]); + }); + + test("does not mutate input when resolved validation fails", () => { + const raw = { + auth: { secret: "${SECRET}", credentials: { ...credentials } }, + }; + const original = structuredClone(raw); + expect(() => validateConfig(raw, { SECRET: "" })).toThrow(ConfigError); + expect(raw).toEqual(original); + }); + + test("accepts credentials with a nonempty secret shorter than 32 characters", () => { + expect( + validateConfig({ auth: { secret: "s", credentials } }, {}).auth, + ).toEqual({ secret: "s", credentials }); + }); + + test("defaults only the OIDC display name and accepts a custom name", () => { + expect(validateConfig({ auth: { secret: "s", oidc } }, {}).auth).toEqual({ + secret: "s", + oidc: { ...oidc, name: "Single Sign-On" }, + }); + expect( + validateConfig( + { auth: { secret: "s", oidc: { ...oidc, name: "Okta SSO" } } }, + {}, + ).auth, + ).toEqual({ + secret: "s", + oidc: { ...oidc, name: "Okta SSO" }, + }); + }); + + test.each([ + ["empty block", {}], + ["missing provider", { secret: "s" }], + ["missing secret", { credentials }], + ["empty secret", { secret: "", credentials }], + ["missing username", { secret: "s", credentials: { password: "p" } }], + [ + "empty username", + { secret: "s", credentials: { username: "", password: "p" } }, + ], + ["missing password", { secret: "s", credentials: { username: "u" } }], + [ + "empty password", + { secret: "s", credentials: { username: "u", password: "" } }, + ], + [ + "missing issuer", + { secret: "s", oidc: { clientId: "c", clientSecret: "s" } }, + ], + [ + "missing clientId", + { secret: "s", oidc: { issuer: "i", clientSecret: "s" } }, + ], + [ + "missing clientSecret", + { secret: "s", oidc: { issuer: "i", clientId: "c" } }, + ], + ["empty issuer", { secret: "s", oidc: { ...oidc, issuer: "" } }], + ["empty clientId", { secret: "s", oidc: { ...oidc, clientId: "" } }], + [ + "empty clientSecret", + { secret: "s", oidc: { ...oidc, clientSecret: "" } }, + ], + ["both providers", { secret: "s", credentials, oidc }], + ["null inactive OIDC", { secret: "s", credentials, oidc: null }], + ["null inactive credentials", { secret: "s", oidc, credentials: null }], + ["legacy enabled", { secret: "s", credentials, enabled: false }], + ["legacy type", { secret: "s", oidc, type: "oidc" }], + ["null auth", null], + ])("rejects auth with %s", (_label, auth) => { + expect(() => validateConfig({ auth }, {})).toThrow(ConfigError); + }); + + test("never infers auth or AI credentials from environment variables", () => { + const env = { + LUZZLE_AUTH_SECRET: "secret", + LUZZLE_AUTH_PASSWORD: "password", + GOOGLE_API_KEY: "key", + OIDC_ISSUER: oidc.issuer, + OIDC_CLIENT_SECRET: oidc.clientSecret, + }; + const config = validateConfig({}, env); + expect(config.auth).toBeUndefined(); + expect(config.ai).toBeUndefined(); + expect(() => validateConfig({ auth: { credentials } }, env)).toThrow( + ConfigError, + ); + expect(() => + validateConfig({ auth: { secret: "s", oidc: {} } }, env), + ).toThrow(ConfigError); + expect(() => validateConfig({ ai: { provider: "google" } }, env)).toThrow( + ConfigError, + ); + }); + + test("accepts explicit AI provider and key", () => { + expect( + validateConfig({ ai: { provider: "google", api_key: "key" } }, {}).ai, + ).toEqual({ + provider: "google", + api_key: "key", + }); + }); + + test.each([ + {}, + { provider: "google" }, + { api_key: "key" }, + { provider: "google", api_key: "" }, + { provider: "other", api_key: "key" }, + null, + ])("rejects incomplete or invalid AI %j", (ai) => { + expect(() => validateConfig({ ai }, {})).toThrow(ConfigError); + }); + + test.each([ + ["root array", []], + ["root scalar", "config"], + ["root false", false], + ["root null", null], + ["url array", { url: [] }], + ["storage array", { storage: [] }], + ["nested sync array", { sync: { archive: [] } }], + ["nested theme array", { theme: { light: [] } }], + ["provider array", { auth: { secret: "s", credentials: [] } }], + ["piece fields array", { pieces: [{ type: "book", fields: [] }] }], + ])( + "rejects %s before defaulting can turn it into an object", + (_label, raw) => { + expect(() => validateConfig(raw, {})).toThrow(ConfigError); + }, + ); + + test.each([ + ["unknown root property", { typo: true }], + ["unknown nested property", { storage: { rot: "./archive" } }], + ["empty storage root", { storage: { root: "" } }], + ["null defaulted section", { storage: null }], + ["null defaulted value", { url: { app: null } }], + [ + "invalid theme enum", + { theme: { markdown: { code: { light: "not-a-theme" } } } }, + ], + [ + "missing piece type", + { pieces: [{ fields: { title: "title", date_consumed: "date" } }] }, + ], + [ + "missing piece title", + { pieces: [{ type: "book", fields: { date_consumed: "date" } }] }, + ], + [ + "missing piece date", + { pieces: [{ type: "book", fields: { title: "title" } }] }, + ], + ["invalid array item", { sync: { archive: { flags: [42] } } }], + ])("rejects %s", (_label, raw) => { + expect(() => validateConfig(raw, {})).toThrow(ConfigError); + }); + + test("reports schema errors as field paths and messages", () => { + const error = validationError({ + storage: { root: "" }, + ai: { provider: "google", api_key: "" }, + }); + expect(error.message).toContain("Configuration validation failed"); + expect(error.issues).toEqual( + expect.arrayContaining([ + { path: "/storage/root", message: expect.any(String) }, + { path: "/ai/api_key", message: expect.any(String) }, + ]), + ); + }); + + test("aggregates missing environment references without including resolved secrets", () => { + const error = validationError( + { + auth: { + secret: "${SECRET}", + credentials: { username: "${USER}", password: "${PASSWORD}" }, + }, + pieces: [ + { + type: "book", + fields: { title: "${TITLE}", date_consumed: "date" }, + }, + ], + }, + { SECRET: "sensitive-value" }, + ); + expect(error.issues).toEqual( + expect.arrayContaining([ + { + path: "/auth/credentials/username", + message: 'Environment variable "USER" is missing.', + }, + { + path: "/auth/credentials/password", + message: 'Environment variable "PASSWORD" is missing.', + }, + { + path: "/pieces/0/fields/title", + message: 'Environment variable "TITLE" is missing.', + }, + ]), + ); + expect(error.message).not.toContain("sensitive-value"); + }); + + test("retains interpolation, fallback, escaping and plain-string behavior", () => { + const config = validateConfig( + { + url: { + app: "http://${HOST}:${PORT}", + app_assets: "${MISSING:-http://localhost:8080/path?query=1}", + luzzle_assets: "${EMPTY}", + }, + auth: { secret: "Value: $${SECRET}", credentials }, + storage: { root: "plain-string" }, + content: { text: { title: "${HOST}", description: "${HOST}" } }, + }, + { HOST: "localhost", PORT: "8080", EMPTY: "", SECRET: "unused" }, + ); + expect(config.url).toEqual({ + app: "http://localhost:8080", + app_assets: "http://localhost:8080/path?query=1", + luzzle_assets: "", + }); + expect(config.auth?.secret).toBe("Value: ${SECRET}"); + expect(config.storage.root).toBe("plain-string"); + expect(config.content.text).toEqual({ + title: "localhost", + description: "localhost", + }); + expect(config.theme.globals["font-size-root"]).toBe(22); + }); + + test("interpolates defaults with the supplied environment", () => { + const config = validateConfig( + {}, + { LUZZLE_APP_URL: "https://example.com", LUZZLE_ASSET_SALT: "salt" }, + ); + expect(config.url.app).toBe("https://example.com"); + expect(config.assets.salt).toBe("salt"); + }); + + test.each([ + { auth: { secret: "${MISSING:-}", credentials } }, + { + auth: { + secret: "s", + credentials: { username: "${EMPTY}", password: "p" }, + }, + }, + { auth: { secret: "s", oidc: { ...oidc, issuer: "${EMPTY}" } } }, + { ai: { provider: "google", api_key: "${EMPTY}" } }, + { storage: { root: "${EMPTY}" } }, + ])("validates nonempty constraints after interpolation: %j", (raw) => { + expect(() => validateConfig(raw, { EMPTY: "" })).toThrow(ConfigError); + }); + + test.each(["8080", "${PORT}"])( + "does not coerce numeric strings %j", + (hmr_port) => { + expect(() => + validateConfig({ network: { public: { hmr_port } } }, { PORT: "8080" }), + ).toThrow(ConfigError); + }, + ); +}); + +// Keep path-helper behavior independent of the schema validation contract. +describe("config path helpers", () => { + describe("getConfigValue", () => { + test("should return the correct value for a given path", () => { + const config = { + a: { + b: { + c: "value", + }, + }, + } as unknown as TestConfig; + const value = getConfigValue(config, "a.b.c"); + expect(value).toBe("value"); + }); + + test("should return undefined for a non-existent path", () => { + const config = { + a: { + b: { + c: "value", + }, + }, + } as unknown as TestConfig; + const value = getConfigValue(config, "a.b.d"); + expect(value).toBeUndefined(); + }); + + test("should return the value for a root-level key", () => { + const config = { + key: "value", + } as unknown as TestConfig; + const value = getConfigValue(config, "key"); + expect(value).toBe("value"); + }); + + test("should return an object for a path to a non-leaf", () => { + const config = { + a: { + b: { + c: "value", + }, + }, + } as unknown as TestConfig; + const value = getConfigValue(config, "a.b"); + expect(value).toEqual({ c: "value" }); + }); + + test("should return undefined for a path starting with a non-existent key", () => { + const config = { + a: { + b: "value", + }, + } as unknown as TestConfig; + const value = getConfigValue(config, "x.y.z"); + expect(value).toBeUndefined(); + }); + + test("should return undefined for an empty config", () => { + const config = {} as unknown as TestConfig; + const value = getConfigValue(config, "a.b"); + expect(value).toBeUndefined(); + }); + }); + + describe("setConfigValue", () => { + test("should correctly set a value at a given path", () => { + const config = { + a: { + b: { + c: "value", + }, + }, + } as unknown as TestConfig; + setConfigValue(config, "a.b.c", "new-value"); + expect(config.a.b.c).toBe("new-value"); + }); + + test("should create intermediate objects if they don't exist", () => { + const config = { + a: {}, + } as unknown as TestConfig; + setConfigValue(config, "a.b.c", "value"); + expect(config.a.b.c).toBe("value"); + }); + + test("should set a value at a root-level key", () => { + const config = {} as unknown as Record; + setConfigValue(config as unknown as TestConfig, "key", "value"); + expect(config.key).toBe("value"); + }); + + test("should overwrite non-object intermediate when setting a nested path", () => { + const config = { + a: { + b: "not-an-object", + }, + } as unknown as TestConfig; + setConfigValue(config, "a.b.c", "value"); + expect((config as unknown as { a: { b: { c: string } } }).a.b.c).toBe( + "value", + ); + }); + + test("should set a value to null", () => { + const config = { + a: { + b: { + c: "value", + }, + }, + } as unknown as TestConfig; + setConfigValue(config, "a.b.c", null); + expect(config.a.b.c).toBeNull(); + }); + + test("should set a value to a number", () => { + const config = { + a: { + b: { + c: "value", + }, + }, + } as unknown as TestConfig; + setConfigValue(config, "a.b.c", 42); + expect(config.a.b.c).toBe(42); + }); + + test("should create deeply nested objects from scratch", () => { + const config = {} as unknown as TestConfig; + setConfigValue(config, "a.b.c.d.e", "deep"); + expect( + (config as unknown as { a: { b: { c: { d: { e: string } } } } }).a.b.c.d + .e, + ).toBe("deep"); + }); + }); +}); diff --git a/packages/web/config/src/lib/config/config.ts b/packages/web/config/src/lib/config/config.ts index 9ca9024d..754ccdef 100644 --- a/packages/web/config/src/lib/config/config.ts +++ b/packages/web/config/src/lib/config/config.ts @@ -1,101 +1,75 @@ -import { readFileSync, existsSync } from 'fs' -import { parse as yamlParse } from 'yaml' -import Ajv from 'ajv' -import { type Schema as Config } from './schema.js' -import schemaJson from './schema.json' with { type: 'json' } +import { readFileSync, existsSync } from "fs"; +import { parse as yamlParse } from "yaml"; +import { Value } from "@sinclair/typebox/value"; +import { ConfigSchema } from "./schema.js"; +import type { Config } from "./schema.js"; +import { createInputSchema } from "./input-schema.js"; +import { ConfigError, configIssues } from "./errors.js"; +import { interpolate } from "./interpolate.js"; -const defaultValidator = new Ajv({ strict: true, useDefaults: true }).compile(schemaJson) -const finalValidator = new Ajv({ strict: true }).compile(schemaJson) +const InputSchema = createInputSchema(ConfigSchema); export type ConfigPublic = { - url: Pick - content: Config['content'] -} + url: Pick; + content: Config["content"]; +}; -function replaceEnvVars(obj: unknown): unknown { - if (typeof obj === 'string') { - return obj.replace(/\$\$|\$\{([^}:]+)(?::-([^}]*))?\}/g, (match, varName, defaultValue) => { - if (match === '$$') { - return '$' - } - - const value = process.env[varName] - if (value !== undefined) { - return value - } - - if (defaultValue !== undefined) { - return defaultValue - } - - console.warn(`Config warning: Environment variable "${varName}" is missing.`) - return match - }) +export function validateConfig( + raw: unknown, + env: NodeJS.ProcessEnv = process.env, +): Config { + // Check input shape before defaults: Value.Default can otherwise turn an array + // supplied for a defaulted object into an apparently valid configuration. + if (!Value.Check(InputSchema, raw)) { + throw new ConfigError(configIssues(Value.Errors(InputSchema, raw))); } - if (Array.isArray(obj)) { - return obj.map(replaceEnvVars) + const defaulted = Value.Default(ConfigSchema, structuredClone(raw)); + const config = interpolate(defaulted, env); + if (!Value.Check(ConfigSchema, config)) { + throw new ConfigError(configIssues(Value.Errors(ConfigSchema, config))); } - - if (obj !== null && typeof obj === 'object') { - const result: Record = {} - for (const key in obj) { - result[key] = replaceEnvVars((obj as Record)[key]) - } - return result - } - - return obj + return config; } function loadConfig(userConfigPath?: string): Config { - let config: Partial = {} - + let raw: unknown = {}; if (userConfigPath && existsSync(userConfigPath)) { - config = (yamlParse(readFileSync(userConfigPath, 'utf8')) as Partial) || {} + raw = yamlParse(readFileSync(userConfigPath, "utf8")) ?? {}; } - if (!defaultValidator(config)) { - throw new Error(`Configuration validation failed: ${defaultValidator.errors?.map(e => e.message).join(', ')}`) - } - - const finalConfig = replaceEnvVars(config) as Config - - if (!finalValidator(finalConfig)) { - throw new Error(`Configuration validation failed: ${finalValidator.errors?.map(e => e.message).join(', ')}`) - } - - if (userConfigPath) { - finalConfig.paths.config = userConfigPath - } - - return finalConfig + const config = validateConfig(raw); + if (userConfigPath) config.paths.config = userConfigPath; + return config; } function getConfigValue(obj: Config, path: string): unknown { - return path.split('.').reduce( + return path.split(".").reduce( (acc, key) => { - if (acc && typeof acc === 'object' && key in acc) { - return acc[key] as Record + if (acc && typeof acc === "object" && key in acc) { + return acc[key] as Record; } - return undefined + return undefined; }, - obj as unknown as undefined | Record - ) + obj as unknown as undefined | Record, + ); } function setConfigValue(obj: Config, path: string, value: unknown): void { - const keys = path.split('.') - const lastKey = keys.pop()! - let current: Record = obj as unknown as Record + const keys = path.split("."); + const lastKey = keys.pop()!; + let current: Record = obj as unknown as Record< + string, + unknown + >; for (const key of keys) { - if (typeof current[key] !== 'object' || current[key] === null) { - current[key] = {} + if (typeof current[key] !== "object" || current[key] === null) { + current[key] = {}; } - current = current[key] as Record + current = current[key] as Record; } - current[lastKey] = value + current[lastKey] = value; } -export { loadConfig, getConfigValue, setConfigValue, type Config } +export { loadConfig, getConfigValue, setConfigValue, type Config }; diff --git a/packages/web/config/src/lib/config/diagnostics.test.ts b/packages/web/config/src/lib/config/diagnostics.test.ts new file mode 100644 index 00000000..5d39230b --- /dev/null +++ b/packages/web/config/src/lib/config/diagnostics.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "vitest"; +import { validateConfig } from "./config.js"; +import { ConfigError } from "./errors.js"; + +function issues(raw: unknown, env: NodeJS.ProcessEnv = {}) { + try { + validateConfig(raw, env); + } catch (error) { + if (error instanceof ConfigError) return error.issues; + throw error; + } + throw new Error("Expected invalid configuration"); +} + +describe("configuration diagnostics", () => { + test("reports a resolved empty session secret at its field, without unrelated provider errors", () => { + const result = issues( + { + auth: { + secret: "${SECRET}", + credentials: { username: "admin", password: "private-password" }, + }, + }, + { SECRET: "" }, + ); + expect(result).toEqual([ + { + path: "/auth/secret", + message: "Expected string length greater or equal to 1", + }, + ]); + expect(JSON.stringify(result)).not.toContain("private-password"); + }); + + test("explains missing credentials fields for the selected credentials provider", () => { + const result = issues({ + auth: { secret: "private-session", credentials: {} }, + }); + expect(new Set(result.map((issue) => issue.path))).toEqual( + new Set(["/auth/credentials/username", "/auth/credentials/password"]), + ); + expect(JSON.stringify(result)).not.toContain("private-session"); + }); + + test("explains missing OIDC fields without suggesting a different provider", () => { + const result = issues({ + auth: { + secret: "private-session", + oidc: { issuer: "https://issuer.example", clientId: "client" }, + }, + }); + expect(new Set(result.map((issue) => issue.path))).toEqual( + new Set(["/auth/oidc/clientSecret"]), + ); + expect( + result.every( + (issue) => Object.keys(issue).sort().join(",") === "message,path", + ), + ).toBe(true); + }); + + test.each([ + null, + "invalid", + {}, + { secret: "s" }, + { + secret: "s", + credentials: { username: "admin", password: "p" }, + oidc: { issuer: "i", clientId: "c", clientSecret: "s" }, + }, + ])( + "keeps an auth-level diagnostic when there is no single selected provider: %j", + (auth) => { + expect(issues({ auth })).toEqual([ + { path: "/auth", message: "Expected union value" }, + ]); + }, + ); + + test("keeps non-auth union errors concise", () => { + expect( + issues({ theme: { markdown: { code: { light: "not-a-theme" } } } }), + ).toEqual([ + { path: "/theme/markdown/code/light", message: "Expected union value" }, + ]); + }); +}); diff --git a/packages/web/config/src/lib/config/errors.ts b/packages/web/config/src/lib/config/errors.ts new file mode 100644 index 00000000..7ce58cfe --- /dev/null +++ b/packages/web/config/src/lib/config/errors.ts @@ -0,0 +1,48 @@ +import { TypeGuard } from "@sinclair/typebox"; +import type { ValueError } from "@sinclair/typebox/errors"; + +export interface ConfigIssue { + path: string; + message: string; +} + +/** Keep TypeBox values/schemas out of diagnostics; explain the selected auth provider. */ +export function configIssues(errors: Iterable): ConfigIssue[] { + const issues: ConfigIssue[] = []; + for (const error of errors) { + let details: Iterable = [error]; + if ( + error.path === "/auth" && + TypeGuard.IsUnion(error.schema) && + error.value !== null && + typeof error.value === "object" + ) { + const credentials = "credentials" in error.value; + const oidc = "oidc" in error.value; + if (credentials !== oidc) { + const provider = credentials ? "credentials" : "oidc"; + const branch = error.schema.anyOf.findIndex( + (schema) => + TypeGuard.IsObject(schema) && provider in schema.properties, + ); + if (branch !== -1) details = error.errors[branch]; + } + } + for (const { path, message } of details) issues.push({ path, message }); + } + return issues; +} + +export class ConfigError extends Error { + readonly issues: readonly ConfigIssue[]; + + constructor(issues: readonly ConfigIssue[]) { + super( + `Configuration validation failed:\n${issues + .map(({ path, message }) => ` ${path || "/"}: ${message}`) + .join("\n")}`, + ); + this.name = "ConfigError"; + this.issues = issues; + } +} diff --git a/packages/web/config/src/lib/config/input-schema.test.ts b/packages/web/config/src/lib/config/input-schema.test.ts new file mode 100644 index 00000000..1571399e --- /dev/null +++ b/packages/web/config/src/lib/config/input-schema.test.ts @@ -0,0 +1,123 @@ +import { Type } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; +import { describe, expect, test } from "vitest"; +import { createInputSchema } from "./input-schema.js"; +import { ConfigSchema } from "./schema.js"; + +const InputSchema = createInputSchema(ConfigSchema); + +describe("configuration input schema", () => { + test("allows sparse overrides without weakening the resolved contract", () => { + const input = { + paths: { database: "./custom.sqlite" }, + theme: { markdown: { code: { light: "min-light" } } }, + }; + expect(Value.Check(InputSchema, input)).toBe(true); + expect(Value.Check(ConfigSchema, input)).toBe(false); + expect(ConfigSchema.required).toContain("url"); + expect(InputSchema.required).toBeUndefined(); + }); + + test("keeps required fields inside piece arrays", () => { + expect( + Value.Check(InputSchema, { + pieces: [{ type: "books", fields: { title: "title" } }], + }), + ).toBe(false); + expect( + Value.Check(InputSchema, { + pieces: [ + { + type: "books", + fields: { title: "title", date_consumed: "date_read" }, + }, + ], + }), + ).toBe(true); + }); + + test("relaxes actual defaults inside array items and unions, not all fields", () => { + const schema = Type.Object({ + entries: Type.Array( + Type.Union([ + Type.Object({ + mode: Type.Literal("a"), + value: Type.String({ default: "value" }), + }), + Type.Object({ mode: Type.Literal("b"), value: Type.String() }), + ]), + { default: [] }, + ), + }); + const relaxed = createInputSchema(schema); + expect(Value.Check(relaxed, {})).toBe(true); + expect(Value.Check(relaxed, { entries: [{ mode: "a" }] })).toBe(true); + expect(Value.Check(relaxed, { entries: [{ mode: "b" }] })).toBe(false); + expect(Value.Check(schema, { entries: [{ mode: "a" }] })).toBe(false); + }); + + test("does not invent authentication, credentials or AI defaults", () => { + expect(Value.Check(InputSchema, {})).toBe(true); + for (const raw of [ + { auth: {} }, + { auth: { credentials: { username: "admin", password: "password" } } }, + { auth: { secret: "s", credentials: { username: "admin" } } }, + { ai: {} }, + { ai: { provider: "google" } }, + { ai: { api_key: "key" } }, + ]) + expect(Value.Check(InputSchema, raw)).toBe(false); + }); + + test("allows omitted OIDC display name but still requires its credentials", () => { + const raw = { + auth: { + secret: "${SECRET}", + oidc: { + issuer: "${ISSUER}", + clientId: "client", + clientSecret: "${CLIENT_SECRET}", + }, + }, + }; + expect(Value.Check(InputSchema, raw)).toBe(true); + expect(Value.Check(InputSchema, { auth: { secret: "s", oidc: {} } })).toBe( + false, + ); + }); + + test("treats false, zero, empty and null defaults as real defaults", () => { + const schema = Type.Object({ + flag: Type.Boolean({ default: false }), + count: Type.Number({ default: 0 }), + text: Type.String({ default: "" }), + nullable: Type.Union([Type.String(), Type.Null()], { default: null }), + }); + expect(Value.Check(createInputSchema(schema), {})).toBe(true); + expect(Value.Check(schema, {})).toBe(false); + }); + + test("does not traverse or alter example/default data as though it were schema", () => { + const data = { + type: "object", + required: ["example"], + properties: { example: { default: "value" } }, + }; + const schema = Type.Object({ + settings: Type.Unknown({ default: data, examples: [data] }), + }); + const before = JSON.stringify(schema); + const input = createInputSchema(schema); + expect(input.properties.settings.default).toEqual(data); + expect(input.properties.settings.examples).toEqual([data]); + expect(JSON.stringify(schema)).toBe(before); + }); + + test("preserves optional fields and produces independently editable copies", () => { + const schema = Type.Object({ value: Type.Optional(Type.String()) }); + const input = createInputSchema(schema); + expect(Value.Check(input, {})).toBe(true); + input.properties.value.description = "Input help"; + expect(schema.properties.value.description).toBeUndefined(); + }); +}); diff --git a/packages/web/config/src/lib/config/input-schema.ts b/packages/web/config/src/lib/config/input-schema.ts new file mode 100644 index 00000000..af35ad3a --- /dev/null +++ b/packages/web/config/src/lib/config/input-schema.ts @@ -0,0 +1,25 @@ +import { TypeGuard } from "@sinclair/typebox"; +import type { TSchema } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; + +/** User input may omit defaulted fields; resolved configuration may not. */ +export function createInputSchema(schema: TSchema): TSchema { + const input = Value.Clone(schema); + + function relax(node: TSchema): void { + if (TypeGuard.IsObject(node)) { + node.required = node.required?.filter( + (key) => !("default" in node.properties[key]), + ); + if (!node.required?.length) delete node.required; + Object.values(node.properties).forEach(relax); + } else if (TypeGuard.IsArray(node)) { + relax(node.items); + } else if (TypeGuard.IsUnion(node)) { + node.anyOf.forEach(relax); + } + } + + relax(input); + return input; +} diff --git a/packages/web/config/src/lib/config/interpolate.test.ts b/packages/web/config/src/lib/config/interpolate.test.ts new file mode 100644 index 00000000..f7849dca --- /dev/null +++ b/packages/web/config/src/lib/config/interpolate.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ConfigError } from "./errors.js"; +import { interpolate } from "./interpolate.js"; + +const issue = (path: string, name: string) => ({ + path, + message: `Environment variable "${name}" is missing.`, +}); + +afterEach(() => vi.unstubAllEnvs()); + +describe("config environment interpolation", () => { + test("substitutes multiple references in a string", () => { + expect( + interpolate("https://${HOST}:${PORT}/", { + HOST: "example.test", + PORT: "8080", + }), + ).toBe("https://example.test:8080/"); + }); + + test("uses process.env when no environment is supplied", () => { + vi.stubEnv("LUZZLE_INTERPOLATION_TEST", "configured"); + expect(interpolate("${LUZZLE_INTERPOLATION_TEST}")).toBe("configured"); + }); + + test("preserves the existing broader environment-name syntax", () => { + expect( + interpolate("${mixedCase}/${WITH-DASH}/${1}", { + mixedCase: "lower", + "WITH-DASH": "dash", + "1": "number", + }), + ).toBe("lower/dash/number"); + }); + + test("uses explicit fallbacks, including empty strings and URLs", () => { + expect( + interpolate(["${MISSING:-}", "${URL:-https://example.test/path}"], {}), + ).toEqual(["", "https://example.test/path"]); + }); + + test("treats an existing empty environment value as present", () => { + expect(interpolate("${EMPTY:-fallback}", { EMPTY: "" })).toBe(""); + }); + + test("treats undefined environment values as missing", () => { + expect(interpolate("${MISSING:-fallback}", { MISSING: undefined })).toBe( + "fallback", + ); + expect(() => interpolate("${MISSING}", { MISSING: undefined })).toThrow( + ConfigError, + ); + }); + + test("expands nested arrays and objects without mutating caller input", () => { + const input = { + auth: { secret: "${SECRET}" }, + sync: { flags: ["--header", "${HEADER}"] }, + count: 0, + enabled: false, + empty: null, + }; + const snapshot = structuredClone(input); + expect( + interpolate(input, { SECRET: "resolved", HEADER: "X-Example: value" }), + ).toEqual({ + auth: { secret: "resolved" }, + sync: { flags: ["--header", "X-Example: value"] }, + count: 0, + enabled: false, + empty: null, + }); + expect(input).toEqual(snapshot); + }); + + test("preserves escaped references instead of interpreting them as missing variables", () => { + expect( + interpolate("$${MISSING} $$ ${PRESENT} $5", { PRESENT: "value" }), + ).toBe("${MISSING} $ value $5"); + }); + + test("does not interpolate replacement values or fallbacks a second time", () => { + expect( + interpolate("${PRESENT}", { PRESENT: "${NOT_ANOTHER_LOOKUP}" }), + ).toBe("${NOT_ANOTHER_LOOKUP}"); + expect(interpolate("${MISSING:-$$}", {})).toBe("$$"); + }); + + test("does not use inherited object properties as environment variables", () => { + expect(() => interpolate("${toString}", {})).toThrow( + 'Environment variable "toString" is missing.', + ); + }); + + test("aggregates missing names and paths, deduplicating repetitions within a field", () => { + const input = { + url: "${Z_HOST}", + auth: { secret: "${A_SECRET}/${A_SECRET}" }, + sync: { flags: ["${Z_HOST}", "${A_SECRET}"] }, + }; + expect(() => interpolate(input, {})).toThrowError( + expect.objectContaining({ + name: "ConfigError", + issues: [ + issue("/auth/secret", "A_SECRET"), + issue("/sync/flags/1", "A_SECRET"), + issue("/sync/flags/0", "Z_HOST"), + issue("/url", "Z_HOST"), + ], + }), + ); + }); + + test("never includes supplied or partially substituted secret values in diagnostics", () => { + let error: unknown; + try { + interpolate( + { auth: { secret: "literal-private-${SUPPLIED}-${MISSING}" } }, + { + SUPPLIED: "environment-private", + }, + ); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(ConfigError); + expect(error).toMatchObject({ issues: [issue("/auth/secret", "MISSING")] }); + expect(String(error)).not.toContain("literal-private"); + expect(String(error)).not.toContain("environment-private"); + expect(JSON.stringify(error)).not.toContain("literal-private"); + expect(JSON.stringify(error)).not.toContain("environment-private"); + }); + + test("uses JSON Pointer escaping for field paths", () => { + expect(() => + interpolate({ "a/b": { "~key": "${MISSING}" } }, {}), + ).toThrowError( + expect.objectContaining({ issues: [issue("/a~1b/~0key", "MISSING")] }), + ); + }); + + test("preserves a __proto__ data key without changing the output prototype", () => { + const input: unknown = JSON.parse('{"__proto__":{"value":"${VALUE}"}}'); + const output = interpolate(input, { VALUE: "resolved" }); + expect(output).toEqual(JSON.parse('{"__proto__":{"value":"resolved"}}')); + expect(Object.getPrototypeOf(output)).toBe(Object.prototype); + }); + + test("formats a root-value error with a readable root path", () => { + expect(() => interpolate("${MISSING}", {})).toThrow( + 'Configuration validation failed:\n /: Environment variable "MISSING" is missing.', + ); + }); + + test("leaves ordinary strings and non-string values unchanged", () => { + for (const value of ["ordinary text", "", 0, false, null, undefined]) { + expect(interpolate(value, {})).toBe(value); + } + }); +}); diff --git a/packages/web/config/src/lib/config/interpolate.ts b/packages/web/config/src/lib/config/interpolate.ts new file mode 100644 index 00000000..16be1b4a --- /dev/null +++ b/packages/web/config/src/lib/config/interpolate.ts @@ -0,0 +1,61 @@ +import { ConfigError } from "./errors.js"; + +const ENV_REFERENCE = /\$\$|\$\{([^}:]+)(?::-([^}]*))?\}/g; + +export function interpolate( + value: unknown, + env: NodeJS.ProcessEnv = process.env, +): unknown { + const missing = new Map>(); + + function visit(node: unknown, path: string): unknown { + if (typeof node === "string") { + return node.replace( + ENV_REFERENCE, + (match, name: string, fallback: string | undefined) => { + if (match === "$$") return "$"; + + const replacement = Object.hasOwn(env, name) ? env[name] : undefined; + if (replacement !== undefined) return replacement; + if (fallback !== undefined) return fallback; + + const paths = missing.get(name) ?? new Set(); + paths.add(path); + missing.set(name, paths); + return match; + }, + ); + } + + if (Array.isArray(node)) { + return node.map((item, index) => visit(item, `${path}/${index}`)); + } + + if (node !== null && typeof node === "object") { + return Object.fromEntries( + Object.entries(node).map(([key, item]) => { + const segment = key.replace(/~/g, "~0").replace(/\//g, "~1"); + return [key, visit(item, `${path}/${segment}`)]; + }), + ); + } + + return node; + } + + const result = visit(value, ""); + if (missing.size > 0) { + throw new ConfigError( + [...missing] + .sort(([left], [right]) => left.localeCompare(right)) + .flatMap(([name, paths]) => + [...paths].sort().map((path) => ({ + path, + message: `Environment variable "${name}" is missing.`, + })), + ), + ); + } + + return result; +} diff --git a/packages/web/config/src/lib/config/schema.json b/packages/web/config/src/lib/config/schema.json deleted file mode 100644 index 40559868..00000000 --- a/packages/web/config/src/lib/config/schema.json +++ /dev/null @@ -1,1166 +0,0 @@ -{ - "title": "Schema", - "type": "object", - "properties": { - "network": { - "type": "object", - "default": {}, - "properties": { - "internal": { - "type": "object", - "default": {}, - "properties": { - "explorer": { - "type": "string", - "default": "http://luzzle-web:3000" - }, - "lsp": { - "type": "string", - "default": "http://luzzle-lsp:9001" - }, - "worker": { - "type": "string", - "default": "http://luzzle-worker:9000" - } - } - }, - "public": { - "type": "object", - "default": {}, - "properties": { - "host": { - "type": "string", - "default": "0.0.0.0" - }, - "hmr_port": { - "type": "number" - } - } - } - } - }, - "url": { - "type": "object", - "default": {}, - "properties": { - "app": { - "type": "string", - "default": "${LUZZLE_APP_URL:-http://localhost:8080}" - }, - "app_assets": { - "type": "string", - "default": "" - }, - "luzzle_assets": { - "type": "string", - "default": "" - } - }, - "required": [ - "app", - "app_assets", - "luzzle_assets" - ], - "additionalProperties": false - }, - "auth": { - "type": "object", - "default": {}, - "properties": { - "enabled": { - "type": "boolean", - "default": false - }, - "secret": { - "type": "string", - "default": "${LUZZLE_AUTH_SECRET:-}" - }, - "type": { - "enum": [ - "oidc", - "credentials" - ], - "default": "oidc" - }, - "oidc": { - "type": "object", - "default": {}, - "properties": { - "name": { - "type": "string", - "default": "Single Sign-On" - }, - "issuer": { - "type": "string", - "default": "" - }, - "clientId": { - "type": "string", - "default": "" - }, - "clientSecret": { - "type": "string", - "default": "" - } - }, - "required": [ - "name", - "issuer", - "clientId", - "clientSecret" - ], - "additionalProperties": false - }, - "credentials": { - "type": "object", - "default": {}, - "properties": { - "username": { - "type": "string", - "default": "${LUZZLE_AUTH_USERNAME:-}" - }, - "password": { - "type": "string", - "default": "${LUZZLE_AUTH_PASSWORD:-}" - } - }, - "required": [ - "username", - "password" - ], - "additionalProperties": false - } - }, - "required": [ - "enabled", - "type", - "secret" - ], - "additionalProperties": false - }, - "storage": { - "type": "object", - "default": {}, - "properties": { - "root": { - "type": "string", - "minLength": 1, - "default": "./archive" - } - }, - "required": [ - "root" - ], - "additionalProperties": false - }, - "sync": { - "type": "object", - "default": {}, - "properties": { - "config": { - "type": "string", - "default": "/app/rclone/rclone.conf" - }, - "archive": { - "type": "object", - "default": {}, - "properties": { - "remote": { - "type": "string", - "default": "" - }, - "path": { - "type": "string", - "default": "" - }, - "flags": { - "type": "array", - "items": { "type": "string" }, - "default": [] - } - }, - "additionalProperties": false - }, - "cdn": { - "type": "object", - "default": {}, - "properties": { - "remote": { - "type": "string", - "default": "" - }, - "path": { - "type": "string", - "default": "" - }, - "flags": { - "type": "array", - "items": { "type": "string" }, - "default": [] - }, - "strategy": { - "type": "string", - "enum": [ - "sync", - "copy" - ], - "default": "sync" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "worker": { - "type": "object", - "default": {}, - "properties": { - "queue": { - "type": "object", - "default": {}, - "properties": { - "path": { - "type": "string", - "default": "./data/sidequest.sqlite" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "ai": { - "type": "object", - "properties": { - "provider": { - "enum": [ - "google" - ], - "default": "google" - }, - "api_key": { - "type": "string", - "minLength": 1, - "default": "" - } - }, - "required": [ - "provider", - "api_key" - ], - "additionalProperties": false - }, - "paths": { - "type": "object", - "default": {}, - "properties": { - "database": { - "type": "string", - "default": "./data/luzzle.sqlite" - }, - "assets": { - "type": "string", - "default": "./assets/pieces" - }, - "cache": { - "type": "string", - "default": "./nginx" - }, - "static": { - "type": "string", - "default": "./static" - }, - "config": { - "type": "string" - } - }, - "required": [ - "database", - "assets", - "cache", - "static" - ], - "additionalProperties": false - }, - "assets": { - "type": "object", - "default": {}, - "properties": { - "salt": { - "type": "string", - "default": "${LUZZLE_ASSET_SALT:-}" - } - }, - "required": [ - "salt" - ], - "additionalProperties": false - }, - "content": { - "type": "object", - "default": {}, - "properties": { - "component": { - "type": "object", - "properties": { - "root": { - "type": "string" - }, - "feed": { - "type": "string" - }, - "404": { - "type": "string" - }, - "error": { - "type": "string" - } - }, - "additionalProperties": false - }, - "text": { - "type": "object", - "default": {}, - "properties": { - "title": { - "type": "string", - "default": "Luzzle Explorer" - }, - "description": { - "type": "string", - "default": "A Luzzle Explorer instance" - } - }, - "required": [ - "title", - "description" - ], - "additionalProperties": false - } - }, - "required": [ - "text" - ], - "additionalProperties": false - }, - "pieces": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "fields": { - "type": "object", - "properties": { - "media": { - "type": "array", - "items": { - "type": "string" - } - }, - "title": { - "type": "string" - }, - "summary": { - "type": "string" - }, - "date_consumed": { - "type": "string" - }, - "tags": { - "type": "string" - }, - "attachments": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false, - "required": [ - "title", - "date_consumed" - ] - }, - "components": { - "type": "object", - "properties": { - "icon": { - "type": "string" - }, - "opengraph": { - "type": "string" - }, - "page": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false, - "required": [ - "type", - "fields" - ] - }, - "default": [] - }, - "theme": { - "type": "object", - "default": {}, - "properties": { - "light": { - "type": "object", - "default": {}, - "properties": { - "color-primary": { - "type": "string", - "default": "#0d6efd" - }, - "color-on-primary": { - "type": "string", - "default": "#ffffff" - }, - "color-primary-container": { - "type": "string", - "default": "#cfe2ff" - }, - "color-on-primary-container": { - "type": "string", - "default": "#084298" - }, - "color-secondary": { - "type": "string", - "default": "#6c757d" - }, - "color-on-secondary": { - "type": "string", - "default": "#ffffff" - }, - "color-secondary-container": { - "type": "string", - "default": "#d3d3d4" - }, - "color-on-secondary-container": { - "type": "string", - "default": "#41464b" - }, - "color-tertiary": { - "type": "string", - "default": "#6c757d" - }, - "color-on-tertiary": { - "type": "string", - "default": "#ffffff" - }, - "color-tertiary-container": { - "type": "string", - "default": "#d3d3d4" - }, - "color-on-tertiary-container": { - "type": "string", - "default": "#41464b" - }, - "color-error": { - "type": "string", - "default": "#dc3545" - }, - "color-on-error": { - "type": "string", - "default": "#ffffff" - }, - "color-error-container": { - "type": "string", - "default": "#f8d7da" - }, - "color-on-error-container": { - "type": "string", - "default": "#842029" - }, - "color-surface": { - "type": "string", - "default": "#f8f9fa" - }, - "color-surface-dim": { - "type": "string", - "default": "#e9ecef" - }, - "color-surface-bright": { - "type": "string", - "default": "#ffffff" - }, - "color-surface-inverse": { - "type": "string", - "default": "#212529" - }, - "color-on-surface": { - "type": "string", - "default": "#212529" - }, - "color-on-surface-variant": { - "type": "string", - "default": "#495057" - }, - "color-on-surface-inverse": { - "type": "string", - "default": "#f8f9fa" - }, - "color-surface-container-lowest": { - "type": "string", - "default": "#ffffff" - }, - "color-surface-container-low": { - "type": "string", - "default": "#e9ecef" - }, - "color-surface-container": { - "type": "string", - "default": "#dee2e6" - }, - "color-surface-container-high": { - "type": "string", - "default": "#ced4da" - }, - "color-surface-container-highest": { - "type": "string", - "default": "#adb5bd" - }, - "color-shadow": { - "type": "string", - "default": "hsla(0, 0%, 0%, 0.15)" - }, - "color-outline": { - "type": "string", - "default": "#6c757d" - }, - "color-outline-variant": { - "type": "string", - "default": "#ced4da" - } - }, - "additionalProperties": false - }, - "dark": { - "type": "object", - "default": {}, - "properties": { - "color-primary": { - "type": "string", - "default": "#3b82f6" - }, - "color-on-primary": { - "type": "string", - "default": "#ffffff" - }, - "color-primary-container": { - "type": "string", - "default": "#0d6efd" - }, - "color-on-primary-container": { - "type": "string", - "default": "#cfe2ff" - }, - "color-secondary": { - "type": "string", - "default": "#adb5bd" - }, - "color-on-secondary": { - "type": "string", - "default": "#212529" - }, - "color-secondary-container": { - "type": "string", - "default": "#495057" - }, - "color-on-secondary-container": { - "type": "string", - "default": "#d3d3d4" - }, - "color-tertiary": { - "type": "string", - "default": "#adb5bd" - }, - "color-on-tertiary": { - "type": "string", - "default": "#212529" - }, - "color-tertiary-container": { - "type": "string", - "default": "#495057" - }, - "color-on-tertiary-container": { - "type": "string", - "default": "#d3d3d4" - }, - "color-error": { - "type": "string", - "default": "#f87171" - }, - "color-on-error": { - "type": "string", - "default": "#4f0b0b" - }, - "color-error-container": { - "type": "string", - "default": "#dc3545" - }, - "color-on-error-container": { - "type": "string", - "default": "#f8d7da" - }, - "color-surface": { - "type": "string", - "default": "#121212" - }, - "color-surface-dim": { - "type": "string", - "default": "#1f1f1f" - }, - "color-surface-bright": { - "type": "string", - "default": "#333333" - }, - "color-surface-inverse": { - "type": "string", - "default": "#e9ecef" - }, - "color-on-surface": { - "type": "string", - "default": "#e9ecef" - }, - "color-on-surface-variant": { - "type": "string", - "default": "#adb5bd" - }, - "color-on-surface-inverse": { - "type": "string", - "default": "#121212" - }, - "color-surface-container-lowest": { - "type": "string", - "default": "#1f1f1f" - }, - "color-surface-container-low": { - "type": "string", - "default": "#333333" - }, - "color-surface-container": { - "type": "string", - "default": "#4f4f4f" - }, - "color-surface-container-high": { - "type": "string", - "default": "#666666" - }, - "color-surface-container-highest": { - "type": "string", - "default": "#7f7f7f" - }, - "color-shadow": { - "type": "string", - "default": "hsla(0, 0%, 0%, 0.5)" - }, - "color-outline": { - "type": "string", - "default": "#6c757d" - }, - "color-outline-variant": { - "type": "string", - "default": "#495057" - } - }, - "additionalProperties": false - }, - "globals": { - "type": "object", - "default": {}, - "properties": { - "shadow-raised": { - "type": "string", - "default": "0px 3px 1px -2px rgba(251 241 199 / 20%), 0px 2px 2px 0px rgba(251 241 199 / 14%), 0px 1px 5px 0px rgba(0 0 0 / 12%)" - }, - "font-mono-name": { - "type": "string", - "default": "\"monaco, monospace\"" - }, - "font-sans-name": { - "type": "string", - "default": "\"Noto Sans\"" - }, - "font-sans-url": { - "type": "string", - "default": "\"/fonts/noto-sans.woff2\"" - }, - "font-sans-weight": { - "type": "string", - "default": "300 600" - }, - "font-size-xxs": { - "type": "string", - "default": "0.7rem" - }, - "font-size-xs": { - "type": "string", - "default": "0.825rem" - }, - "font-size-small": { - "type": "string", - "default": "1rem" - }, - "font-size-normal": { - "type": "string", - "default": "1.25rem" - }, - "font-size-medium": { - "type": "string", - "default": "1.5rem" - }, - "font-size-large": { - "type": "string", - "default": "1.75rem" - }, - "font-size-xl": { - "type": "string", - "default": "2rem" - }, - "font-size-xxl": { - "type": "string", - "default": "2.25rem" - }, - "font-size-mobile-responsive-factor": { - "type": "number", - "default": 0.8 - }, - "font-size-root": { - "type": "number", - "default": 22 - }, - "font-weight-light": { - "type": "number", - "default": 300 - }, - "font-weight-normal": { - "type": "number", - "default": 400 - }, - "font-weight-medium": { - "type": "number", - "default": 500 - }, - "font-weight-semibold": { - "type": "number", - "default": 550 - }, - "font-weight-bold": { - "type": "number", - "default": 600 - }, - "radius-none": { - "type": "string", - "default": "0" - }, - "radius-small": { - "type": "string", - "default": "0.25rem" - }, - "radius-medium": { - "type": "string", - "default": "0.375rem" - }, - "radius-large": { - "type": "string", - "default": "0.5rem" - }, - "radius-xl": { - "type": "string", - "default": "0.75rem" - }, - "radius-x2l": { - "type": "string", - "default": "1rem" - }, - "radius-x3l": { - "type": "string", - "default": "1.5rem" - }, - "radius-x4l": { - "type": "string", - "default": "2.5rem" - }, - "radius-full": { - "type": "string", - "default": "9999px" - }, - "breakpoint-phone": { - "type": "number", - "default": 640 - }, - "breakpoint-tablet": { - "type": "number", - "default": 768 - }, - "breakpoint-laptop": { - "type": "number", - "default": 1024 - }, - "breakpoint-desktop": { - "type": "number", - "default": 1280 - }, - "space-1": { - "type": "string", - "default": "0.25rem" - }, - "space-2": { - "type": "string", - "default": "0.5rem" - }, - "space-3": { - "type": "string", - "default": "0.75rem" - }, - "space-4": { - "type": "string", - "default": "1rem" - }, - "space-5": { - "type": "string", - "default": "1.25rem" - }, - "space-6": { - "type": "string", - "default": "1.5rem" - }, - "space-7": { - "type": "string", - "default": "1.75rem" - }, - "space-8": { - "type": "string", - "default": "2rem" - }, - "space-9": { - "type": "string", - "default": "2.25rem" - }, - "space-10": { - "type": "string", - "default": "2.5rem" - }, - "space-0-5": { - "type": "string", - "default": "0.125rem" - }, - "space-1-5": { - "type": "string", - "default": "0.375rem" - }, - "space-2-5": { - "type": "string", - "default": "0.625rem" - }, - "space-3-5": { - "type": "string", - "default": "0.875rem" - } - }, - "additionalProperties": false - }, - "markdown": { - "type": "object", - "default": {}, - "properties": { - "sidenote": { - "type": "object", - "default": {}, - "properties": { - "sidenote-callout-before-content": { - "type": "string", - "default": "\"[ \"" - }, - "sidenote-callout-after-content": { - "type": "string", - "default": "\" ]\"" - }, - "sidenote-citation-before-content": { - "type": "string", - "default": "\"\"" - }, - "sidenote-citation-after-content": { - "type": "string", - "default": "\":\"" - } - }, - "additionalProperties": false - }, - "code": { - "type": "object", - "default": {}, - "properties": { - "light": { - "enum": [ - "andromeeda", - "aurora-x", - "ayu-dark", - "catppuccin-frappe", - "catppuccin-latte", - "catppuccin-macchiato", - "catppuccin-mocha", - "dark-plus", - "dracula", - "dracula-soft", - "everforest-dark", - "everforest-light", - "github-dark", - "github-dark-default", - "github-dark-dimmed", - "github-dark-high-contrast", - "github-light", - "github-light-default", - "github-light-high-contrast", - "gruvbox-dark-hard", - "gruvbox-dark-medium", - "gruvbox-dark-soft", - "gruvbox-light-hard", - "gruvbox-light-medium", - "gruvbox-light-soft", - "houston", - "kanagawa-dragon", - "kanagawa-lotus", - "kanagawa-wave", - "laserwave", - "light-plus", - "material-theme", - "material-theme-darker", - "material-theme-lighter", - "material-theme-ocean", - "material-theme-palenight", - "min-dark", - "min-light", - "monokai", - "night-owl", - "nord", - "one-dark-pro", - "one-light", - "plastic", - "poimandres", - "red", - "rose-pine", - "rose-pine-dawn", - "rose-pine-moon", - "slack-dark", - "slack-ochin", - "snazzy-light", - "solarized-dark", - "solarized-light", - "synthwave-84", - "tokyo-night", - "vesper", - "vitesse-black", - "vitesse-dark", - "vitesse-light" - ], - "default": "github-light" - }, - "dark": { - "enum": [ - "andromeeda", - "aurora-x", - "ayu-dark", - "catppuccin-frappe", - "catppuccin-latte", - "catppuccin-macchiato", - "catppuccin-mocha", - "dark-plus", - "dracula", - "dracula-soft", - "everforest-dark", - "everforest-light", - "github-dark", - "github-dark-default", - "github-dark-dimmed", - "github-dark-high-contrast", - "github-light", - "github-light-default", - "github-light-high-contrast", - "gruvbox-dark-hard", - "gruvbox-dark-medium", - "gruvbox-dark-soft", - "gruvbox-light-hard", - "gruvbox-light-medium", - "gruvbox-light-soft", - "houston", - "kanagawa-dragon", - "kanagawa-lotus", - "kanagawa-wave", - "laserwave", - "light-plus", - "material-theme", - "material-theme-darker", - "material-theme-lighter", - "material-theme-ocean", - "material-theme-palenight", - "min-dark", - "min-light", - "monokai", - "night-owl", - "nord", - "one-dark-pro", - "one-light", - "plastic", - "poimandres", - "red", - "rose-pine", - "rose-pine-dawn", - "rose-pine-moon", - "slack-dark", - "slack-ochin", - "snazzy-light", - "solarized-dark", - "solarized-light", - "synthwave-84", - "tokyo-night", - "vesper", - "vitesse-black", - "vitesse-dark", - "vitesse-light" - ], - "default": "github-dark" - } - }, - "required": [ - "light", - "dark" - ], - "additionalProperties": false - } - }, - "required": [ - "code", - "sidenote" - ], - "additionalProperties": false - } - }, - "required": [ - "globals", - "light", - "dark", - "markdown" - ], - "additionalProperties": false - } - }, - "required": [ - "url", - "auth", - "storage", - "sync", - "content", - "paths", - "assets", - "theme", - "pieces" - ], - "if": { - "type": "object", - "properties": { - "auth": { - "type": "object", - "properties": { - "enabled": { - "const": true - } - } - } - } - }, - "then": { - "type": "object", - "properties": { - "auth": { - "type": "object", - "properties": { - "secret": { - "type": "string", - "minLength": 1 - } - } - } - }, - "if": { - "type": "object", - "properties": { - "auth": { - "type": "object", - "properties": { - "type": { - "const": "credentials" - } - } - } - } - }, - "then": { - "type": "object", - "properties": { - "auth": { - "type": "object", - "required": [ - "credentials" - ], - "properties": { - "credentials": { - "type": "object", - "properties": { - "username": { - "type": "string", - "minLength": 1 - }, - "password": { - "type": "string", - "minLength": 1 - } - } - } - } - } - } - } - }, - "allOf": [ - { - "if": { - "type": "object", - "properties": { - "ai": { - "type": "object" - } - }, - "required": [ - "ai" - ] - }, - "then": { - "type": "object", - "properties": { - "ai": { - "type": "object", - "properties": { - "api_key": { - "type": "string", - "minLength": 1 - } - } - } - } - } - } - ], - "additionalProperties": false -} diff --git a/packages/web/config/src/lib/config/schema.ts b/packages/web/config/src/lib/config/schema.ts index 64e255a5..d97212e0 100644 --- a/packages/web/config/src/lib/config/schema.ts +++ b/packages/web/config/src/lib/config/schema.ts @@ -1,358 +1,722 @@ -/* eslint-disable */ -/** - * This file was automatically generated by json-schema-to-typescript. - * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, - * and run json-schema-to-typescript to regenerate this file. - */ +import { Type } from "@sinclair/typebox"; +import type { Static } from "@sinclair/typebox"; -export type Schema = { - [k: string]: unknown; -} & { - network?: { - internal?: { - explorer?: string; - lsp?: string; - worker?: string; - [k: string]: unknown; - }; - public?: { - host?: string; - hmr_port?: number; - [k: string]: unknown; - }; - [k: string]: unknown; - }; - url: { - app: string; - app_assets: string; - luzzle_assets: string; - }; - auth: { - enabled: boolean; - secret: string; - type: "oidc" | "credentials"; - oidc?: { - name: string; - issuer: string; - clientId: string; - clientSecret: string; - }; - credentials?: { - username: string; - password: string; - }; - }; - storage: { - root: string; - }; - sync: { - config?: string; - archive?: { - remote?: string; - path?: string; - flags?: string[]; - }; - cdn?: { - remote?: string; - path?: string; - flags?: string[]; - strategy?: "sync" | "copy"; - }; - }; - worker?: { - queue?: { - path?: string; - }; - }; - ai?: { - provider: "google"; - api_key: string; - }; - paths: { - database: string; - assets: string; - cache: string; - static: string; - config?: string; - }; - assets: { - salt: string; - }; - content: { - component?: { - "404"?: string; - root?: string; - feed?: string; - error?: string; - }; - text: { - title: string; - description: string; - }; - }; - pieces: { - type: string; - fields: { - media?: string[]; - title: string; - summary?: string; - date_consumed: string; - tags?: string; - attachments?: string[]; - }; - components?: { - icon?: string; - opengraph?: string; - page?: string; - }; - }[]; - theme: { - light: { - "color-primary"?: string; - "color-on-primary"?: string; - "color-primary-container"?: string; - "color-on-primary-container"?: string; - "color-secondary"?: string; - "color-on-secondary"?: string; - "color-secondary-container"?: string; - "color-on-secondary-container"?: string; - "color-tertiary"?: string; - "color-on-tertiary"?: string; - "color-tertiary-container"?: string; - "color-on-tertiary-container"?: string; - "color-error"?: string; - "color-on-error"?: string; - "color-error-container"?: string; - "color-on-error-container"?: string; - "color-surface"?: string; - "color-surface-dim"?: string; - "color-surface-bright"?: string; - "color-surface-inverse"?: string; - "color-on-surface"?: string; - "color-on-surface-variant"?: string; - "color-on-surface-inverse"?: string; - "color-surface-container-lowest"?: string; - "color-surface-container-low"?: string; - "color-surface-container"?: string; - "color-surface-container-high"?: string; - "color-surface-container-highest"?: string; - "color-shadow"?: string; - "color-outline"?: string; - "color-outline-variant"?: string; - }; - dark: { - "color-primary"?: string; - "color-on-primary"?: string; - "color-primary-container"?: string; - "color-on-primary-container"?: string; - "color-secondary"?: string; - "color-on-secondary"?: string; - "color-secondary-container"?: string; - "color-on-secondary-container"?: string; - "color-tertiary"?: string; - "color-on-tertiary"?: string; - "color-tertiary-container"?: string; - "color-on-tertiary-container"?: string; - "color-error"?: string; - "color-on-error"?: string; - "color-error-container"?: string; - "color-on-error-container"?: string; - "color-surface"?: string; - "color-surface-dim"?: string; - "color-surface-bright"?: string; - "color-surface-inverse"?: string; - "color-on-surface"?: string; - "color-on-surface-variant"?: string; - "color-on-surface-inverse"?: string; - "color-surface-container-lowest"?: string; - "color-surface-container-low"?: string; - "color-surface-container"?: string; - "color-surface-container-high"?: string; - "color-surface-container-highest"?: string; - "color-shadow"?: string; - "color-outline"?: string; - "color-outline-variant"?: string; - }; - globals: { - "shadow-raised"?: string; - "font-mono-name"?: string; - "font-sans-name"?: string; - "font-sans-url"?: string; - "font-sans-weight"?: string; - "font-size-xxs"?: string; - "font-size-xs"?: string; - "font-size-small"?: string; - "font-size-normal"?: string; - "font-size-medium"?: string; - "font-size-large"?: string; - "font-size-xl"?: string; - "font-size-xxl"?: string; - "font-size-mobile-responsive-factor"?: number; - "font-size-root"?: number; - "font-weight-light"?: number; - "font-weight-normal"?: number; - "font-weight-medium"?: number; - "font-weight-semibold"?: number; - "font-weight-bold"?: number; - "radius-none"?: string; - "radius-small"?: string; - "radius-medium"?: string; - "radius-large"?: string; - "radius-xl"?: string; - "radius-x2l"?: string; - "radius-x3l"?: string; - "radius-x4l"?: string; - "radius-full"?: string; - "breakpoint-phone"?: number; - "breakpoint-tablet"?: number; - "breakpoint-laptop"?: number; - "breakpoint-desktop"?: number; - "space-1"?: string; - "space-2"?: string; - "space-3"?: string; - "space-4"?: string; - "space-5"?: string; - "space-6"?: string; - "space-7"?: string; - "space-8"?: string; - "space-9"?: string; - "space-10"?: string; - "space-0-5"?: string; - "space-1-5"?: string; - "space-2-5"?: string; - "space-3-5"?: string; - }; - markdown: { - sidenote: { - "sidenote-callout-before-content"?: string; - "sidenote-callout-after-content"?: string; - "sidenote-citation-before-content"?: string; - "sidenote-citation-after-content"?: string; - }; - code: { - light: - | "andromeeda" - | "aurora-x" - | "ayu-dark" - | "catppuccin-frappe" - | "catppuccin-latte" - | "catppuccin-macchiato" - | "catppuccin-mocha" - | "dark-plus" - | "dracula" - | "dracula-soft" - | "everforest-dark" - | "everforest-light" - | "github-dark" - | "github-dark-default" - | "github-dark-dimmed" - | "github-dark-high-contrast" - | "github-light" - | "github-light-default" - | "github-light-high-contrast" - | "gruvbox-dark-hard" - | "gruvbox-dark-medium" - | "gruvbox-dark-soft" - | "gruvbox-light-hard" - | "gruvbox-light-medium" - | "gruvbox-light-soft" - | "houston" - | "kanagawa-dragon" - | "kanagawa-lotus" - | "kanagawa-wave" - | "laserwave" - | "light-plus" - | "material-theme" - | "material-theme-darker" - | "material-theme-lighter" - | "material-theme-ocean" - | "material-theme-palenight" - | "min-dark" - | "min-light" - | "monokai" - | "night-owl" - | "nord" - | "one-dark-pro" - | "one-light" - | "plastic" - | "poimandres" - | "red" - | "rose-pine" - | "rose-pine-dawn" - | "rose-pine-moon" - | "slack-dark" - | "slack-ochin" - | "snazzy-light" - | "solarized-dark" - | "solarized-light" - | "synthwave-84" - | "tokyo-night" - | "vesper" - | "vitesse-black" - | "vitesse-dark" - | "vitesse-light"; - dark: - | "andromeeda" - | "aurora-x" - | "ayu-dark" - | "catppuccin-frappe" - | "catppuccin-latte" - | "catppuccin-macchiato" - | "catppuccin-mocha" - | "dark-plus" - | "dracula" - | "dracula-soft" - | "everforest-dark" - | "everforest-light" - | "github-dark" - | "github-dark-default" - | "github-dark-dimmed" - | "github-dark-high-contrast" - | "github-light" - | "github-light-default" - | "github-light-high-contrast" - | "gruvbox-dark-hard" - | "gruvbox-dark-medium" - | "gruvbox-dark-soft" - | "gruvbox-light-hard" - | "gruvbox-light-medium" - | "gruvbox-light-soft" - | "houston" - | "kanagawa-dragon" - | "kanagawa-lotus" - | "kanagawa-wave" - | "laserwave" - | "light-plus" - | "material-theme" - | "material-theme-darker" - | "material-theme-lighter" - | "material-theme-ocean" - | "material-theme-palenight" - | "min-dark" - | "min-light" - | "monokai" - | "night-owl" - | "nord" - | "one-dark-pro" - | "one-light" - | "plastic" - | "poimandres" - | "red" - | "rose-pine" - | "rose-pine-dawn" - | "rose-pine-moon" - | "slack-dark" - | "slack-ochin" - | "snazzy-light" - | "solarized-dark" - | "solarized-light" - | "synthwave-84" - | "tokyo-night" - | "vesper" - | "vitesse-black" - | "vitesse-dark" - | "vitesse-light"; - }; - }; - }; -}; +const NetworkSchema = Type.Object( + { + internal: Type.Object( + { + explorer: Type.String({ + default: "http://luzzle-web:3000", + description: + "Explorer address used to allow its hostname in the development server.", + }), + lsp: Type.String({ + default: "http://luzzle-lsp:9001", + description: + "Reserved LSP address. Proxy routing is configured separately with LUZZLE_PROXY_LSP_UPSTREAM.", + }), + worker: Type.String({ + default: "http://luzzle-worker:9000", + description: "Worker service address for health checks.", + }), + }, + { default: {} }, + ), + public: Type.Object( + { + host: Type.String({ + default: "0.0.0.0", + description: + "Development server bind address, not the public site URL.", + }), + hmr_port: Type.Optional( + Type.Number({ + description: + "Vite hot-reload client port, when different from the server port.", + }), + ), + }, + { default: {} }, + ), + }, + { + default: {}, + description: "Internal service and development network settings.", + }, +); + +const UrlSchema = Type.Object( + { + app: Type.String({ + default: "${LUZZLE_APP_URL:-http://localhost:8080}", + description: + "Public Explorer URL. Supports an explicit environment reference.", + }), + app_assets: Type.String({ + default: "", + description: + "Application asset base URL. Empty uses same-origin paths; a CDN URL is allowed.", + }), + luzzle_assets: Type.String({ + default: "", + description: + "Published piece asset base URL. Empty uses same-origin paths; a CDN URL is allowed.", + }), + }, + { + default: {}, + additionalProperties: false, + description: "Public site and asset URLs.", + }, +); + +const SessionSecretSchema = Type.String({ + minLength: 1, + description: + "Session-signing secret. Supply a strong random value or an explicit environment reference; required whenever auth is configured.", + examples: ["${LUZZLE_AUTH_SECRET}"], +}); + +const CredentialsSchema = Type.Object( + { + username: Type.String({ + minLength: 1, + description: "Administrator username.", + examples: ["admin"], + }), + password: Type.String({ + minLength: 1, + description: + "Administrator password or an explicit environment reference.", + examples: ["${LUZZLE_AUTH_PASSWORD}"], + }), + }, + { + additionalProperties: false, + description: "Local username/password authentication.", + }, +); + +const OidcSchema = Type.Object( + { + name: Type.String({ + default: "Single Sign-On", + description: "Label shown on the sign-in button.", + }), + issuer: Type.String({ + minLength: 1, + description: "OIDC issuer URL or an explicit environment reference.", + examples: ["https://identity.example.com", "${OIDC_ISSUER}"], + }), + clientId: Type.String({ + minLength: 1, + description: "OIDC client identifier.", + }), + clientSecret: Type.String({ + minLength: 1, + description: "OIDC client secret or an explicit environment reference.", + examples: ["${OIDC_CLIENT_SECRET}"], + }), + }, + { + additionalProperties: false, + description: "OpenID Connect authentication.", + }, +); + +const AuthSchema = Type.Union( + [ + Type.Object( + { secret: SessionSecretSchema, credentials: CredentialsSchema }, + { title: "Credentials", additionalProperties: false }, + ), + Type.Object( + { secret: SessionSecretSchema, oidc: OidcSchema }, + { title: "OIDC", additionalProperties: false }, + ), + ], + { + description: + "Configure exactly one authentication provider and a session secret. Omit auth for a public-only site with admin access disabled.", + }, +); + +const AiSchema = Type.Object( + { + provider: Type.Literal("google", { + description: + "Generation provider. Google is currently the supported provider; this field is required.", + }), + api_key: Type.String({ + minLength: 1, + description: "Provider API key or an explicit environment reference.", + examples: ["${GOOGLE_API_KEY}"], + }), + }, + { + additionalProperties: false, + description: + "AI generation settings. Omit ai to disable generation; both provider and api_key must be supplied when configured.", + }, +); + +const StorageSchema = Type.Object( + { + root: Type.String({ + minLength: 1, + default: "./archive", + description: "Local Markdown archive directory.", + }), + }, + { default: {}, additionalProperties: false }, +); + +const SyncSchema = Type.Object( + { + config: Type.String({ + default: "/app/rclone/rclone.conf", + description: "rclone configuration file used by the worker.", + }), + archive: Type.Object( + { + remote: Type.String({ + default: "", + description: + "rclone remote name, not an s3:// URL. Empty remote or path skips archive synchronization.", + }), + path: Type.String({ + default: "", + description: + "Remote archive path, combined with the remote name as remote:path.", + }), + flags: Type.Array(Type.String(), { + default: [], + description: + "Additional rclone arguments, one argument per array entry.", + }), + }, + { + default: {}, + additionalProperties: false, + description: + "Bidirectional archive synchronization using rclone bisync.", + }, + ), + cdn: Type.Object( + { + remote: Type.String({ + default: "", + description: + "rclone remote name. Empty remote or path skips CDN synchronization.", + }), + path: Type.String({ + default: "", + description: "Remote published-assets path, combined as remote:path.", + }), + flags: Type.Array(Type.String(), { + default: [], + description: + "Additional rclone arguments, one argument per array entry.", + }), + strategy: Type.Union([Type.Literal("sync"), Type.Literal("copy")], { + default: "sync", + description: "sync deletes destination-only files; copy does not.", + }), + }, + { + default: {}, + additionalProperties: false, + description: "Publish generated assets to a remote destination.", + }, + ), + }, + { default: {}, additionalProperties: false }, +); + +const WorkerSchema = Type.Object( + { + queue: Type.Object( + { + path: Type.String({ + default: "./data/sidequest.sqlite", + description: + "Workflow queue database path. The legacy sidequest.sqlite filename is mapped to openworkflow.sqlite by the application.", + }), + }, + { default: {}, additionalProperties: false }, + ), + }, + { default: {}, additionalProperties: false }, +); + +const PathsSchema = Type.Object( + { + database: Type.String({ + default: "./data/luzzle.sqlite", + description: + "SQLite index path, resolved relative to the config directory.", + }), + assets: Type.String({ + default: "./assets/pieces", + description: + "Generated piece assets directory. Worker generation uses its working directory for relative paths.", + }), + cache: Type.String({ + default: "./nginx", + description: "Proxy cache directory.", + }), + static: Type.String({ + default: "./static", + description: "Static render assets directory.", + }), + config: Type.Optional( + Type.String({ + description: + "Runtime metadata: loadConfig records the supplied config filename here.", + readOnly: true, + }), + ), + }, + { default: {}, additionalProperties: false }, +); + +const AssetsSchema = Type.Object( + { + salt: Type.String({ + default: "${LUZZLE_ASSET_SALT:-}", + description: + "Salt for generated asset keys. Changing it changes published asset URLs.", + }), + }, + { default: {}, additionalProperties: false }, +); + +const ContentSchema = Type.Object( + { + component: Type.Optional( + Type.Object( + { + root: Type.Optional( + Type.String({ + description: "Svelte source path for the home page.", + }), + ), + feed: Type.Optional( + Type.String({ description: "Svelte source path for the feed." }), + ), + "404": Type.Optional( + Type.String({ + description: "Svelte source path for the not-found page.", + }), + ), + error: Type.Optional( + Type.String({ + description: "Svelte source path for the error page.", + }), + ), + }, + { additionalProperties: false }, + ), + ), + text: Type.Object( + { + title: Type.String({ + default: "Luzzle Explorer", + description: "Site title.", + }), + description: Type.String({ + default: "A Luzzle Explorer instance", + description: "Site description.", + }), + }, + { default: {}, additionalProperties: false }, + ), + }, + { default: {}, additionalProperties: false }, +); + +const PiecesSchema = Type.Array( + Type.Object( + { + type: Type.String({ + description: "Archive piece type associated with this presentation.", + examples: ["books"], + }), + fields: Type.Object( + { + media: Type.Optional( + Type.Array(Type.String(), { + description: "Frontmatter selectors for media assets.", + examples: [["cover"]], + }), + ), + title: Type.String({ + description: + "Frontmatter selector for the title, not a literal display title.", + examples: ["title"], + }), + summary: Type.Optional( + Type.String({ + description: "Frontmatter selector for a summary.", + examples: ["description"], + }), + ), + date_consumed: Type.String({ + description: + "Frontmatter selector for the consumed/read/viewed date.", + examples: ["date_read"], + }), + tags: Type.Optional( + Type.String({ + description: "Frontmatter selector for tags.", + examples: ["keywords"], + }), + ), + attachments: Type.Optional( + Type.Array(Type.String(), { + description: "Frontmatter selectors for attachments.", + examples: [["files[*].file"]], + }), + ), + }, + { additionalProperties: false }, + ), + components: Type.Optional( + Type.Object( + { + icon: Type.Optional( + Type.String({ + description: "Svelte source path for the piece icon.", + }), + ), + opengraph: Type.Optional( + Type.String({ + description: "Svelte source path for the Open Graph image.", + }), + ), + page: Type.Optional( + Type.String({ + description: "Svelte source path for the piece page.", + }), + ), + }, + { additionalProperties: false }, + ), + ), + }, + { additionalProperties: false }, + ), + { + default: [], + description: + "Ordered presentation and frontmatter mappings for archive piece types.", + }, +); + +const codeThemeNames = [ + "andromeeda", + "aurora-x", + "ayu-dark", + "catppuccin-frappe", + "catppuccin-latte", + "catppuccin-macchiato", + "catppuccin-mocha", + "dark-plus", + "dracula", + "dracula-soft", + "everforest-dark", + "everforest-light", + "github-dark", + "github-dark-default", + "github-dark-dimmed", + "github-dark-high-contrast", + "github-light", + "github-light-default", + "github-light-high-contrast", + "gruvbox-dark-hard", + "gruvbox-dark-medium", + "gruvbox-dark-soft", + "gruvbox-light-hard", + "gruvbox-light-medium", + "gruvbox-light-soft", + "houston", + "kanagawa-dragon", + "kanagawa-lotus", + "kanagawa-wave", + "laserwave", + "light-plus", + "material-theme", + "material-theme-darker", + "material-theme-lighter", + "material-theme-ocean", + "material-theme-palenight", + "min-dark", + "min-light", + "monokai", + "night-owl", + "nord", + "one-dark-pro", + "one-light", + "plastic", + "poimandres", + "red", + "rose-pine", + "rose-pine-dawn", + "rose-pine-moon", + "slack-dark", + "slack-ochin", + "snazzy-light", + "solarized-dark", + "solarized-light", + "synthwave-84", + "tokyo-night", + "vesper", + "vitesse-black", + "vitesse-dark", + "vitesse-light", +] as const; +const codeThemeChoices = codeThemeNames.map((name) => Type.Literal(name)); + +const LightPaletteSchema = Type.Object( + { + "color-primary": Type.String({ default: "#0d6efd" }), + "color-on-primary": Type.String({ default: "#ffffff" }), + "color-primary-container": Type.String({ default: "#cfe2ff" }), + "color-on-primary-container": Type.String({ default: "#084298" }), + "color-secondary": Type.String({ default: "#6c757d" }), + "color-on-secondary": Type.String({ default: "#ffffff" }), + "color-secondary-container": Type.String({ default: "#d3d3d4" }), + "color-on-secondary-container": Type.String({ default: "#41464b" }), + "color-tertiary": Type.String({ default: "#6c757d" }), + "color-on-tertiary": Type.String({ default: "#ffffff" }), + "color-tertiary-container": Type.String({ default: "#d3d3d4" }), + "color-on-tertiary-container": Type.String({ default: "#41464b" }), + "color-error": Type.String({ default: "#dc3545" }), + "color-on-error": Type.String({ default: "#ffffff" }), + "color-error-container": Type.String({ default: "#f8d7da" }), + "color-on-error-container": Type.String({ default: "#842029" }), + "color-surface": Type.String({ default: "#f8f9fa" }), + "color-surface-dim": Type.String({ default: "#e9ecef" }), + "color-surface-bright": Type.String({ default: "#ffffff" }), + "color-surface-inverse": Type.String({ default: "#212529" }), + "color-on-surface": Type.String({ default: "#212529" }), + "color-on-surface-variant": Type.String({ default: "#495057" }), + "color-on-surface-inverse": Type.String({ default: "#f8f9fa" }), + "color-surface-container-lowest": Type.String({ default: "#ffffff" }), + "color-surface-container-low": Type.String({ default: "#e9ecef" }), + "color-surface-container": Type.String({ default: "#dee2e6" }), + "color-surface-container-high": Type.String({ default: "#ced4da" }), + "color-surface-container-highest": Type.String({ default: "#adb5bd" }), + "color-shadow": Type.String({ default: "hsla(0, 0%, 0%, 0.15)" }), + "color-outline": Type.String({ default: "#6c757d" }), + "color-outline-variant": Type.String({ default: "#ced4da" }), + }, + { + default: {}, + additionalProperties: false, + description: + "Light palette CSS color values; hex, rgb, hsl and other CSS color syntax are supported.", + }, +); + +const DarkPaletteSchema = Type.Object( + { + "color-primary": Type.String({ default: "#3b82f6" }), + "color-on-primary": Type.String({ default: "#ffffff" }), + "color-primary-container": Type.String({ default: "#0d6efd" }), + "color-on-primary-container": Type.String({ default: "#cfe2ff" }), + "color-secondary": Type.String({ default: "#adb5bd" }), + "color-on-secondary": Type.String({ default: "#212529" }), + "color-secondary-container": Type.String({ default: "#495057" }), + "color-on-secondary-container": Type.String({ default: "#d3d3d4" }), + "color-tertiary": Type.String({ default: "#adb5bd" }), + "color-on-tertiary": Type.String({ default: "#212529" }), + "color-tertiary-container": Type.String({ default: "#495057" }), + "color-on-tertiary-container": Type.String({ default: "#d3d3d4" }), + "color-error": Type.String({ default: "#f87171" }), + "color-on-error": Type.String({ default: "#4f0b0b" }), + "color-error-container": Type.String({ default: "#dc3545" }), + "color-on-error-container": Type.String({ default: "#f8d7da" }), + "color-surface": Type.String({ default: "#121212" }), + "color-surface-dim": Type.String({ default: "#1f1f1f" }), + "color-surface-bright": Type.String({ default: "#333333" }), + "color-surface-inverse": Type.String({ default: "#e9ecef" }), + "color-on-surface": Type.String({ default: "#e9ecef" }), + "color-on-surface-variant": Type.String({ default: "#adb5bd" }), + "color-on-surface-inverse": Type.String({ default: "#121212" }), + "color-surface-container-lowest": Type.String({ default: "#1f1f1f" }), + "color-surface-container-low": Type.String({ default: "#333333" }), + "color-surface-container": Type.String({ default: "#4f4f4f" }), + "color-surface-container-high": Type.String({ default: "#666666" }), + "color-surface-container-highest": Type.String({ default: "#7f7f7f" }), + "color-shadow": Type.String({ default: "hsla(0, 0%, 0%, 0.5)" }), + "color-outline": Type.String({ default: "#6c757d" }), + "color-outline-variant": Type.String({ default: "#495057" }), + }, + { + default: {}, + additionalProperties: false, + description: "Dark palette CSS color values.", + }, +); + +const GlobalsSchema = Type.Object( + { + "shadow-raised": Type.String({ + default: + "0px 3px 1px -2px rgba(251 241 199 / 20%), 0px 2px 2px 0px rgba(251 241 199 / 14%), 0px 1px 5px 0px rgba(0 0 0 / 12%)", + }), + "font-mono-name": Type.String({ + default: '"monaco, monospace"', + description: "CSS font-family value for monospace text.", + }), + "font-sans-name": Type.String({ + default: '"Noto Sans"', + description: "CSS font-family value for sans-serif text.", + }), + "font-sans-url": Type.String({ + default: '"/fonts/noto-sans.woff2"', + description: "CSS-quoted font URL.", + }), + "font-sans-weight": Type.String({ + default: "300 600", + description: "Font-face weight or variable font weight range.", + }), + "font-size-xxs": Type.String({ default: "0.7rem" }), + "font-size-xs": Type.String({ default: "0.825rem" }), + "font-size-small": Type.String({ default: "1rem" }), + "font-size-normal": Type.String({ default: "1.25rem" }), + "font-size-medium": Type.String({ default: "1.5rem" }), + "font-size-large": Type.String({ default: "1.75rem" }), + "font-size-xl": Type.String({ default: "2rem" }), + "font-size-xxl": Type.String({ default: "2.25rem" }), + "font-size-mobile-responsive-factor": Type.Number({ + default: 0.8, + description: "Mobile font-size multiplier.", + }), + "font-size-root": Type.Number({ + default: 22, + description: "Root font size in pixels.", + }), + "font-weight-light": Type.Number({ default: 300 }), + "font-weight-normal": Type.Number({ default: 400 }), + "font-weight-medium": Type.Number({ default: 500 }), + "font-weight-semibold": Type.Number({ default: 550 }), + "font-weight-bold": Type.Number({ default: 600 }), + "radius-none": Type.String({ default: "0" }), + "radius-small": Type.String({ default: "0.25rem" }), + "radius-medium": Type.String({ default: "0.375rem" }), + "radius-large": Type.String({ default: "0.5rem" }), + "radius-xl": Type.String({ default: "0.75rem" }), + "radius-x2l": Type.String({ default: "1rem" }), + "radius-x3l": Type.String({ default: "1.5rem" }), + "radius-x4l": Type.String({ default: "2.5rem" }), + "radius-full": Type.String({ default: "9999px" }), + "breakpoint-phone": Type.Number({ + default: 640, + description: "Phone breakpoint in pixels.", + }), + "breakpoint-tablet": Type.Number({ + default: 768, + description: "Tablet breakpoint in pixels.", + }), + "breakpoint-laptop": Type.Number({ + default: 1024, + description: "Laptop breakpoint in pixels.", + }), + "breakpoint-desktop": Type.Number({ + default: 1280, + description: "Desktop breakpoint in pixels.", + }), + "space-1": Type.String({ default: "0.25rem" }), + "space-2": Type.String({ default: "0.5rem" }), + "space-3": Type.String({ default: "0.75rem" }), + "space-4": Type.String({ default: "1rem" }), + "space-5": Type.String({ default: "1.25rem" }), + "space-6": Type.String({ default: "1.5rem" }), + "space-7": Type.String({ default: "1.75rem" }), + "space-8": Type.String({ default: "2rem" }), + "space-9": Type.String({ default: "2.25rem" }), + "space-10": Type.String({ default: "2.5rem" }), + "space-0-5": Type.String({ default: "0.125rem" }), + "space-1-5": Type.String({ default: "0.375rem" }), + "space-2-5": Type.String({ default: "0.625rem" }), + "space-3-5": Type.String({ default: "0.875rem" }), + }, + { + default: {}, + additionalProperties: false, + description: + "Global CSS tokens. Sizes, spacing and radii are CSS-length strings unless a field explicitly uses a number.", + }, +); + +const MarkdownThemeSchema = Type.Object( + { + sidenote: Type.Object( + { + "sidenote-callout-before-content": Type.String({ default: '"[ "' }), + "sidenote-callout-after-content": Type.String({ default: '" ]"' }), + "sidenote-citation-before-content": Type.String({ default: '""' }), + "sidenote-citation-after-content": Type.String({ default: '":"' }), + }, + { + default: {}, + additionalProperties: false, + description: + "CSS content values for sidenotes; include CSS string quotes inside the YAML value.", + }, + ), + code: Type.Object( + { + light: Type.Union(codeThemeChoices, { + default: "github-light", + description: + "Shiki theme for light-mode code blocks and both editors.", + }), + dark: Type.Union(codeThemeChoices, { + default: "github-dark", + description: + "Shiki theme for dark-mode code blocks and both editors.", + }), + }, + { default: {}, additionalProperties: false }, + ), + }, + { default: {}, additionalProperties: false }, +); + +const ThemeSchema = Type.Object( + { + light: LightPaletteSchema, + dark: DarkPaletteSchema, + globals: GlobalsSchema, + markdown: MarkdownThemeSchema, + }, + { + default: {}, + additionalProperties: false, + description: + "Theme overrides. Omit any defaulted tokens you do not need to customize.", + }, +); + +export const ConfigSchema = Type.Object( + { + network: NetworkSchema, + url: UrlSchema, + auth: Type.Optional(AuthSchema), + storage: StorageSchema, + sync: SyncSchema, + worker: WorkerSchema, + ai: Type.Optional(AiSchema), + paths: PathsSchema, + assets: AssetsSchema, + content: ContentSchema, + pieces: PiecesSchema, + theme: ThemeSchema, + }, + { title: "Luzzle web configuration", additionalProperties: false }, +); + +export type Config = Static; diff --git a/packages/web/config/src/lib/config/web.config.schema.json b/packages/web/config/src/lib/config/web.config.schema.json index 3829d7d9..8ec9a8d2 100644 --- a/packages/web/config/src/lib/config/web.config.schema.json +++ b/packages/web/config/src/lib/config/web.config.schema.json @@ -1,38 +1,46 @@ { - "title": "Schema", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Luzzle web configuration", + "additionalProperties": false, "type": "object", "properties": { "network": { - "type": "object", "default": {}, + "description": "Internal service and development network settings.", + "type": "object", "properties": { "internal": { - "type": "object", "default": {}, + "type": "object", "properties": { "explorer": { - "type": "string", - "default": "http://luzzle-web:3000" + "default": "http://luzzle-web:3000", + "description": "Explorer address used to allow its hostname in the development server.", + "type": "string" }, "lsp": { - "type": "string", - "default": "http://luzzle-lsp:9001" + "default": "http://luzzle-lsp:9001", + "description": "Reserved LSP address. Proxy routing is configured separately with LUZZLE_PROXY_LSP_UPSTREAM.", + "type": "string" }, "worker": { - "type": "string", - "default": "http://luzzle-worker:9000" + "default": "http://luzzle-worker:9000", + "description": "Worker service address for health checks.", + "type": "string" } } }, "public": { - "type": "object", "default": {}, + "type": "object", "properties": { "host": { - "type": "string", - "default": "0.0.0.0" + "default": "0.0.0.0", + "description": "Development server bind address, not the public site URL.", + "type": "string" }, "hmr_port": { + "description": "Vite hot-reload client port, when different from the server port.", "type": "number" } } @@ -40,1027 +48,1464 @@ } }, "url": { - "type": "object", "default": {}, + "additionalProperties": false, + "description": "Public site and asset URLs.", + "type": "object", "properties": { "app": { - "type": "string", - "default": "${LUZZLE_APP_URL:-http://localhost:8080}" + "default": "${LUZZLE_APP_URL:-http://localhost:8080}", + "description": "Public Explorer URL. Supports an explicit environment reference.", + "type": "string" }, "app_assets": { - "type": "string", - "default": "" + "default": "", + "description": "Application asset base URL. Empty uses same-origin paths; a CDN URL is allowed.", + "type": "string" }, "luzzle_assets": { - "type": "string", - "default": "" - } - }, - "additionalProperties": false - }, - "auth": { - "type": "object", - "default": {}, - "properties": { - "enabled": { - "type": "boolean", - "default": false - }, - "secret": { - "type": "string", - "default": "${LUZZLE_AUTH_SECRET:-}" - }, - "type": { - "enum": [ - "oidc", - "credentials" - ], - "default": "oidc" - }, - "oidc": { - "type": "object", - "default": {}, - "properties": { - "name": { - "type": "string", - "default": "Single Sign-On" - }, - "issuer": { - "type": "string", - "default": "" - }, - "clientId": { - "type": "string", - "default": "" - }, - "clientSecret": { - "type": "string", - "default": "" - } - }, - "additionalProperties": false - }, - "credentials": { - "type": "object", - "default": {}, - "properties": { - "username": { - "type": "string", - "default": "${LUZZLE_AUTH_USERNAME:-}" - }, - "password": { - "type": "string", - "default": "${LUZZLE_AUTH_PASSWORD:-}" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "storage": { - "type": "object", - "default": {}, - "properties": { - "root": { - "type": "string", - "minLength": 1, - "default": "./archive" - } - }, - "additionalProperties": false - }, - "sync": { - "type": "object", - "default": {}, - "properties": { - "config": { - "type": "string", - "default": "/app/rclone/rclone.conf" - }, - "archive": { - "type": "object", - "default": {}, - "properties": { - "remote": { - "type": "string", - "default": "" - }, - "path": { - "type": "string", - "default": "" - }, - "flags": { - "type": "array", - "items": { - "type": "string" - }, - "default": [] - } - }, - "additionalProperties": false - }, - "cdn": { - "type": "object", - "default": {}, - "properties": { - "remote": { - "type": "string", - "default": "" - }, - "path": { - "type": "string", - "default": "" - }, - "flags": { - "type": "array", - "items": { - "type": "string" - }, - "default": [] - }, - "strategy": { - "type": "string", - "enum": [ - "sync", - "copy" - ], - "default": "sync" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "worker": { - "type": "object", - "default": {}, - "properties": { - "queue": { - "type": "object", - "default": {}, - "properties": { - "path": { - "type": "string", - "default": "./data/sidequest.sqlite" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "ai": { - "type": "object", - "properties": { - "provider": { - "enum": [ - "google" - ], - "default": "google" - }, - "api_key": { - "type": "string", - "minLength": 1, - "default": "" - } - }, - "additionalProperties": false - }, - "paths": { - "type": "object", - "default": {}, - "properties": { - "database": { - "type": "string", - "default": "./data/luzzle.sqlite" - }, - "assets": { - "type": "string", - "default": "./assets/pieces" - }, - "cache": { - "type": "string", - "default": "./nginx" - }, - "static": { - "type": "string", - "default": "./static" - }, - "config": { + "default": "", + "description": "Published piece asset base URL. Empty uses same-origin paths; a CDN URL is allowed.", "type": "string" } - }, - "additionalProperties": false + } + }, + "auth": { + "description": "Configure exactly one authentication provider and a session secret. Omit auth for a public-only site with admin access disabled.", + "anyOf": [ + { + "title": "Credentials", + "additionalProperties": false, + "type": "object", + "required": [ + "secret", + "credentials" + ], + "properties": { + "secret": { + "minLength": 1, + "description": "Session-signing secret. Supply a strong random value or an explicit environment reference; required whenever auth is configured.", + "examples": [ + "${LUZZLE_AUTH_SECRET}" + ], + "type": "string" + }, + "credentials": { + "additionalProperties": false, + "description": "Local username/password authentication.", + "type": "object", + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "minLength": 1, + "description": "Administrator username.", + "examples": [ + "admin" + ], + "type": "string" + }, + "password": { + "minLength": 1, + "description": "Administrator password or an explicit environment reference.", + "examples": [ + "${LUZZLE_AUTH_PASSWORD}" + ], + "type": "string" + } + } + } + } + }, + { + "title": "OIDC", + "additionalProperties": false, + "type": "object", + "required": [ + "secret", + "oidc" + ], + "properties": { + "secret": { + "minLength": 1, + "description": "Session-signing secret. Supply a strong random value or an explicit environment reference; required whenever auth is configured.", + "examples": [ + "${LUZZLE_AUTH_SECRET}" + ], + "type": "string" + }, + "oidc": { + "additionalProperties": false, + "description": "OpenID Connect authentication.", + "type": "object", + "required": [ + "issuer", + "clientId", + "clientSecret" + ], + "properties": { + "name": { + "default": "Single Sign-On", + "description": "Label shown on the sign-in button.", + "type": "string" + }, + "issuer": { + "minLength": 1, + "description": "OIDC issuer URL or an explicit environment reference.", + "examples": [ + "https://identity.example.com", + "${OIDC_ISSUER}" + ], + "type": "string" + }, + "clientId": { + "minLength": 1, + "description": "OIDC client identifier.", + "type": "string" + }, + "clientSecret": { + "minLength": 1, + "description": "OIDC client secret or an explicit environment reference.", + "examples": [ + "${OIDC_CLIENT_SECRET}" + ], + "type": "string" + } + } + } + } + } + ] + }, + "storage": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "root": { + "minLength": 1, + "default": "./archive", + "description": "Local Markdown archive directory.", + "type": "string" + } + } + }, + "sync": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "config": { + "default": "/app/rclone/rclone.conf", + "description": "rclone configuration file used by the worker.", + "type": "string" + }, + "archive": { + "default": {}, + "additionalProperties": false, + "description": "Bidirectional archive synchronization using rclone bisync.", + "type": "object", + "properties": { + "remote": { + "default": "", + "description": "rclone remote name, not an s3:// URL. Empty remote or path skips archive synchronization.", + "type": "string" + }, + "path": { + "default": "", + "description": "Remote archive path, combined with the remote name as remote:path.", + "type": "string" + }, + "flags": { + "default": [], + "description": "Additional rclone arguments, one argument per array entry.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "cdn": { + "default": {}, + "additionalProperties": false, + "description": "Publish generated assets to a remote destination.", + "type": "object", + "properties": { + "remote": { + "default": "", + "description": "rclone remote name. Empty remote or path skips CDN synchronization.", + "type": "string" + }, + "path": { + "default": "", + "description": "Remote published-assets path, combined as remote:path.", + "type": "string" + }, + "flags": { + "default": [], + "description": "Additional rclone arguments, one argument per array entry.", + "type": "array", + "items": { + "type": "string" + } + }, + "strategy": { + "default": "sync", + "description": "sync deletes destination-only files; copy does not.", + "anyOf": [ + { + "const": "sync", + "type": "string" + }, + { + "const": "copy", + "type": "string" + } + ] + } + } + } + } + }, + "worker": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "queue": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "path": { + "default": "./data/sidequest.sqlite", + "description": "Workflow queue database path. The legacy sidequest.sqlite filename is mapped to openworkflow.sqlite by the application.", + "type": "string" + } + } + } + } + }, + "ai": { + "additionalProperties": false, + "description": "AI generation settings. Omit ai to disable generation; both provider and api_key must be supplied when configured.", + "type": "object", + "required": [ + "provider", + "api_key" + ], + "properties": { + "provider": { + "description": "Generation provider. Google is currently the supported provider; this field is required.", + "const": "google", + "type": "string" + }, + "api_key": { + "minLength": 1, + "description": "Provider API key or an explicit environment reference.", + "examples": [ + "${GOOGLE_API_KEY}" + ], + "type": "string" + } + } + }, + "paths": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "database": { + "default": "./data/luzzle.sqlite", + "description": "SQLite index path, resolved relative to the config directory.", + "type": "string" + }, + "assets": { + "default": "./assets/pieces", + "description": "Generated piece assets directory. Worker generation uses its working directory for relative paths.", + "type": "string" + }, + "cache": { + "default": "./nginx", + "description": "Proxy cache directory.", + "type": "string" + }, + "static": { + "default": "./static", + "description": "Static render assets directory.", + "type": "string" + }, + "config": { + "description": "Runtime metadata: loadConfig records the supplied config filename here.", + "readOnly": true, + "type": "string" + } + } }, "assets": { - "type": "object", "default": {}, + "additionalProperties": false, + "type": "object", "properties": { "salt": { - "type": "string", - "default": "${LUZZLE_ASSET_SALT:-}" + "default": "${LUZZLE_ASSET_SALT:-}", + "description": "Salt for generated asset keys. Changing it changes published asset URLs.", + "type": "string" } - }, - "additionalProperties": false + } }, "content": { - "type": "object", "default": {}, + "additionalProperties": false, + "type": "object", "properties": { "component": { + "additionalProperties": false, "type": "object", "properties": { "404": { + "description": "Svelte source path for the not-found page.", "type": "string" }, "root": { + "description": "Svelte source path for the home page.", "type": "string" }, "feed": { + "description": "Svelte source path for the feed.", "type": "string" }, "error": { + "description": "Svelte source path for the error page.", "type": "string" } - }, - "additionalProperties": false + } }, "text": { - "type": "object", "default": {}, + "additionalProperties": false, + "type": "object", "properties": { "title": { - "type": "string", - "default": "Luzzle Explorer" + "default": "Luzzle Explorer", + "description": "Site title.", + "type": "string" }, "description": { - "type": "string", - "default": "A Luzzle Explorer instance" + "default": "A Luzzle Explorer instance", + "description": "Site description.", + "type": "string" } - }, - "additionalProperties": false + } } - }, - "additionalProperties": false + } }, "pieces": { + "default": [], + "description": "Ordered presentation and frontmatter mappings for archive piece types.", "type": "array", "items": { + "additionalProperties": false, "type": "object", + "required": [ + "type", + "fields" + ], "properties": { "type": { + "description": "Archive piece type associated with this presentation.", + "examples": [ + "books" + ], "type": "string" }, "fields": { + "additionalProperties": false, "type": "object", + "required": [ + "title", + "date_consumed" + ], "properties": { "media": { + "description": "Frontmatter selectors for media assets.", + "examples": [ + [ + "cover" + ] + ], "type": "array", "items": { "type": "string" } }, "title": { + "description": "Frontmatter selector for the title, not a literal display title.", + "examples": [ + "title" + ], "type": "string" }, "summary": { + "description": "Frontmatter selector for a summary.", + "examples": [ + "description" + ], "type": "string" }, "date_consumed": { + "description": "Frontmatter selector for the consumed/read/viewed date.", + "examples": [ + "date_read" + ], "type": "string" }, "tags": { + "description": "Frontmatter selector for tags.", + "examples": [ + "keywords" + ], "type": "string" }, "attachments": { + "description": "Frontmatter selectors for attachments.", + "examples": [ + [ + "files[*].file" + ] + ], "type": "array", "items": { "type": "string" } } - }, - "additionalProperties": false, - "required": [ - "title", - "date_consumed" - ] + } }, "components": { + "additionalProperties": false, "type": "object", "properties": { "icon": { + "description": "Svelte source path for the piece icon.", "type": "string" }, "opengraph": { + "description": "Svelte source path for the Open Graph image.", "type": "string" }, "page": { + "description": "Svelte source path for the piece page.", "type": "string" } - }, - "additionalProperties": false + } } - }, - "additionalProperties": false, - "required": [ - "type", - "fields" - ] - }, - "default": [] + } + } }, "theme": { - "type": "object", "default": {}, + "additionalProperties": false, + "description": "Theme overrides. Omit any defaulted tokens you do not need to customize.", + "type": "object", "properties": { "light": { - "type": "object", "default": {}, + "additionalProperties": false, + "description": "Light palette CSS color values; hex, rgb, hsl and other CSS color syntax are supported.", + "type": "object", "properties": { "color-primary": { - "type": "string", - "default": "#0d6efd" + "default": "#0d6efd", + "type": "string" }, "color-on-primary": { - "type": "string", - "default": "#ffffff" + "default": "#ffffff", + "type": "string" }, "color-primary-container": { - "type": "string", - "default": "#cfe2ff" + "default": "#cfe2ff", + "type": "string" }, "color-on-primary-container": { - "type": "string", - "default": "#084298" + "default": "#084298", + "type": "string" }, "color-secondary": { - "type": "string", - "default": "#6c757d" + "default": "#6c757d", + "type": "string" }, "color-on-secondary": { - "type": "string", - "default": "#ffffff" + "default": "#ffffff", + "type": "string" }, "color-secondary-container": { - "type": "string", - "default": "#d3d3d4" + "default": "#d3d3d4", + "type": "string" }, "color-on-secondary-container": { - "type": "string", - "default": "#41464b" + "default": "#41464b", + "type": "string" }, "color-tertiary": { - "type": "string", - "default": "#6c757d" + "default": "#6c757d", + "type": "string" }, "color-on-tertiary": { - "type": "string", - "default": "#ffffff" + "default": "#ffffff", + "type": "string" }, "color-tertiary-container": { - "type": "string", - "default": "#d3d3d4" + "default": "#d3d3d4", + "type": "string" }, "color-on-tertiary-container": { - "type": "string", - "default": "#41464b" + "default": "#41464b", + "type": "string" }, "color-error": { - "type": "string", - "default": "#dc3545" + "default": "#dc3545", + "type": "string" }, "color-on-error": { - "type": "string", - "default": "#ffffff" + "default": "#ffffff", + "type": "string" }, "color-error-container": { - "type": "string", - "default": "#f8d7da" + "default": "#f8d7da", + "type": "string" }, "color-on-error-container": { - "type": "string", - "default": "#842029" + "default": "#842029", + "type": "string" }, "color-surface": { - "type": "string", - "default": "#f8f9fa" + "default": "#f8f9fa", + "type": "string" }, "color-surface-dim": { - "type": "string", - "default": "#e9ecef" + "default": "#e9ecef", + "type": "string" }, "color-surface-bright": { - "type": "string", - "default": "#ffffff" + "default": "#ffffff", + "type": "string" }, "color-surface-inverse": { - "type": "string", - "default": "#212529" + "default": "#212529", + "type": "string" }, "color-on-surface": { - "type": "string", - "default": "#212529" + "default": "#212529", + "type": "string" }, "color-on-surface-variant": { - "type": "string", - "default": "#495057" + "default": "#495057", + "type": "string" }, "color-on-surface-inverse": { - "type": "string", - "default": "#f8f9fa" + "default": "#f8f9fa", + "type": "string" }, "color-surface-container-lowest": { - "type": "string", - "default": "#ffffff" + "default": "#ffffff", + "type": "string" }, "color-surface-container-low": { - "type": "string", - "default": "#e9ecef" + "default": "#e9ecef", + "type": "string" }, "color-surface-container": { - "type": "string", - "default": "#dee2e6" + "default": "#dee2e6", + "type": "string" }, "color-surface-container-high": { - "type": "string", - "default": "#ced4da" + "default": "#ced4da", + "type": "string" }, "color-surface-container-highest": { - "type": "string", - "default": "#adb5bd" + "default": "#adb5bd", + "type": "string" }, "color-shadow": { - "type": "string", - "default": "hsla(0, 0%, 0%, 0.15)" + "default": "hsla(0, 0%, 0%, 0.15)", + "type": "string" }, "color-outline": { - "type": "string", - "default": "#6c757d" + "default": "#6c757d", + "type": "string" }, "color-outline-variant": { - "type": "string", - "default": "#ced4da" + "default": "#ced4da", + "type": "string" } - }, - "additionalProperties": false + } }, "dark": { - "type": "object", "default": {}, + "additionalProperties": false, + "description": "Dark palette CSS color values.", + "type": "object", "properties": { "color-primary": { - "type": "string", - "default": "#3b82f6" + "default": "#3b82f6", + "type": "string" }, "color-on-primary": { - "type": "string", - "default": "#ffffff" + "default": "#ffffff", + "type": "string" }, "color-primary-container": { - "type": "string", - "default": "#0d6efd" + "default": "#0d6efd", + "type": "string" }, "color-on-primary-container": { - "type": "string", - "default": "#cfe2ff" + "default": "#cfe2ff", + "type": "string" }, "color-secondary": { - "type": "string", - "default": "#adb5bd" + "default": "#adb5bd", + "type": "string" }, "color-on-secondary": { - "type": "string", - "default": "#212529" + "default": "#212529", + "type": "string" }, "color-secondary-container": { - "type": "string", - "default": "#495057" + "default": "#495057", + "type": "string" }, "color-on-secondary-container": { - "type": "string", - "default": "#d3d3d4" + "default": "#d3d3d4", + "type": "string" }, "color-tertiary": { - "type": "string", - "default": "#adb5bd" + "default": "#adb5bd", + "type": "string" }, "color-on-tertiary": { - "type": "string", - "default": "#212529" + "default": "#212529", + "type": "string" }, "color-tertiary-container": { - "type": "string", - "default": "#495057" + "default": "#495057", + "type": "string" }, "color-on-tertiary-container": { - "type": "string", - "default": "#d3d3d4" + "default": "#d3d3d4", + "type": "string" }, "color-error": { - "type": "string", - "default": "#f87171" + "default": "#f87171", + "type": "string" }, "color-on-error": { - "type": "string", - "default": "#4f0b0b" + "default": "#4f0b0b", + "type": "string" }, "color-error-container": { - "type": "string", - "default": "#dc3545" + "default": "#dc3545", + "type": "string" }, "color-on-error-container": { - "type": "string", - "default": "#f8d7da" + "default": "#f8d7da", + "type": "string" }, "color-surface": { - "type": "string", - "default": "#121212" + "default": "#121212", + "type": "string" }, "color-surface-dim": { - "type": "string", - "default": "#1f1f1f" + "default": "#1f1f1f", + "type": "string" }, "color-surface-bright": { - "type": "string", - "default": "#333333" + "default": "#333333", + "type": "string" }, "color-surface-inverse": { - "type": "string", - "default": "#e9ecef" + "default": "#e9ecef", + "type": "string" }, "color-on-surface": { - "type": "string", - "default": "#e9ecef" + "default": "#e9ecef", + "type": "string" }, "color-on-surface-variant": { - "type": "string", - "default": "#adb5bd" + "default": "#adb5bd", + "type": "string" }, "color-on-surface-inverse": { - "type": "string", - "default": "#121212" + "default": "#121212", + "type": "string" }, "color-surface-container-lowest": { - "type": "string", - "default": "#1f1f1f" + "default": "#1f1f1f", + "type": "string" }, "color-surface-container-low": { - "type": "string", - "default": "#333333" + "default": "#333333", + "type": "string" }, "color-surface-container": { - "type": "string", - "default": "#4f4f4f" + "default": "#4f4f4f", + "type": "string" }, "color-surface-container-high": { - "type": "string", - "default": "#666666" + "default": "#666666", + "type": "string" }, "color-surface-container-highest": { - "type": "string", - "default": "#7f7f7f" + "default": "#7f7f7f", + "type": "string" }, "color-shadow": { - "type": "string", - "default": "hsla(0, 0%, 0%, 0.5)" + "default": "hsla(0, 0%, 0%, 0.5)", + "type": "string" }, "color-outline": { - "type": "string", - "default": "#6c757d" + "default": "#6c757d", + "type": "string" }, "color-outline-variant": { - "type": "string", - "default": "#495057" + "default": "#495057", + "type": "string" } - }, - "additionalProperties": false + } }, "globals": { - "type": "object", "default": {}, + "additionalProperties": false, + "description": "Global CSS tokens. Sizes, spacing and radii are CSS-length strings unless a field explicitly uses a number.", + "type": "object", "properties": { "shadow-raised": { - "type": "string", - "default": "0px 3px 1px -2px rgba(251 241 199 / 20%), 0px 2px 2px 0px rgba(251 241 199 / 14%), 0px 1px 5px 0px rgba(0 0 0 / 12%)" + "default": "0px 3px 1px -2px rgba(251 241 199 / 20%), 0px 2px 2px 0px rgba(251 241 199 / 14%), 0px 1px 5px 0px rgba(0 0 0 / 12%)", + "type": "string" }, "font-mono-name": { - "type": "string", - "default": "\"monaco, monospace\"" + "default": "\"monaco, monospace\"", + "description": "CSS font-family value for monospace text.", + "type": "string" }, "font-sans-name": { - "type": "string", - "default": "\"Noto Sans\"" + "default": "\"Noto Sans\"", + "description": "CSS font-family value for sans-serif text.", + "type": "string" }, "font-sans-url": { - "type": "string", - "default": "\"/fonts/noto-sans.woff2\"" + "default": "\"/fonts/noto-sans.woff2\"", + "description": "CSS-quoted font URL.", + "type": "string" }, "font-sans-weight": { - "type": "string", - "default": "300 600" + "default": "300 600", + "description": "Font-face weight or variable font weight range.", + "type": "string" }, "font-size-xxs": { - "type": "string", - "default": "0.7rem" + "default": "0.7rem", + "type": "string" }, "font-size-xs": { - "type": "string", - "default": "0.825rem" + "default": "0.825rem", + "type": "string" }, "font-size-small": { - "type": "string", - "default": "1rem" + "default": "1rem", + "type": "string" }, "font-size-normal": { - "type": "string", - "default": "1.25rem" + "default": "1.25rem", + "type": "string" }, "font-size-medium": { - "type": "string", - "default": "1.5rem" + "default": "1.5rem", + "type": "string" }, "font-size-large": { - "type": "string", - "default": "1.75rem" + "default": "1.75rem", + "type": "string" }, "font-size-xl": { - "type": "string", - "default": "2rem" + "default": "2rem", + "type": "string" }, "font-size-xxl": { - "type": "string", - "default": "2.25rem" + "default": "2.25rem", + "type": "string" }, "font-size-mobile-responsive-factor": { - "type": "number", - "default": 0.8 + "default": 0.8, + "description": "Mobile font-size multiplier.", + "type": "number" }, "font-size-root": { - "type": "number", - "default": 22 + "default": 22, + "description": "Root font size in pixels.", + "type": "number" }, "font-weight-light": { - "type": "number", - "default": 300 + "default": 300, + "type": "number" }, "font-weight-normal": { - "type": "number", - "default": 400 + "default": 400, + "type": "number" }, "font-weight-medium": { - "type": "number", - "default": 500 + "default": 500, + "type": "number" }, "font-weight-semibold": { - "type": "number", - "default": 550 + "default": 550, + "type": "number" }, "font-weight-bold": { - "type": "number", - "default": 600 + "default": 600, + "type": "number" }, "radius-none": { - "type": "string", - "default": "0" + "default": "0", + "type": "string" }, "radius-small": { - "type": "string", - "default": "0.25rem" + "default": "0.25rem", + "type": "string" }, "radius-medium": { - "type": "string", - "default": "0.375rem" + "default": "0.375rem", + "type": "string" }, "radius-large": { - "type": "string", - "default": "0.5rem" + "default": "0.5rem", + "type": "string" }, "radius-xl": { - "type": "string", - "default": "0.75rem" + "default": "0.75rem", + "type": "string" }, "radius-x2l": { - "type": "string", - "default": "1rem" + "default": "1rem", + "type": "string" }, "radius-x3l": { - "type": "string", - "default": "1.5rem" + "default": "1.5rem", + "type": "string" }, "radius-x4l": { - "type": "string", - "default": "2.5rem" + "default": "2.5rem", + "type": "string" }, "radius-full": { - "type": "string", - "default": "9999px" + "default": "9999px", + "type": "string" }, "breakpoint-phone": { - "type": "number", - "default": 640 + "default": 640, + "description": "Phone breakpoint in pixels.", + "type": "number" }, "breakpoint-tablet": { - "type": "number", - "default": 768 + "default": 768, + "description": "Tablet breakpoint in pixels.", + "type": "number" }, "breakpoint-laptop": { - "type": "number", - "default": 1024 + "default": 1024, + "description": "Laptop breakpoint in pixels.", + "type": "number" }, "breakpoint-desktop": { - "type": "number", - "default": 1280 + "default": 1280, + "description": "Desktop breakpoint in pixels.", + "type": "number" }, "space-1": { - "type": "string", - "default": "0.25rem" + "default": "0.25rem", + "type": "string" }, "space-2": { - "type": "string", - "default": "0.5rem" + "default": "0.5rem", + "type": "string" }, "space-3": { - "type": "string", - "default": "0.75rem" + "default": "0.75rem", + "type": "string" }, "space-4": { - "type": "string", - "default": "1rem" + "default": "1rem", + "type": "string" }, "space-5": { - "type": "string", - "default": "1.25rem" + "default": "1.25rem", + "type": "string" }, "space-6": { - "type": "string", - "default": "1.5rem" + "default": "1.5rem", + "type": "string" }, "space-7": { - "type": "string", - "default": "1.75rem" + "default": "1.75rem", + "type": "string" }, "space-8": { - "type": "string", - "default": "2rem" + "default": "2rem", + "type": "string" }, "space-9": { - "type": "string", - "default": "2.25rem" + "default": "2.25rem", + "type": "string" }, "space-10": { - "type": "string", - "default": "2.5rem" + "default": "2.5rem", + "type": "string" }, "space-0-5": { - "type": "string", - "default": "0.125rem" + "default": "0.125rem", + "type": "string" }, "space-1-5": { - "type": "string", - "default": "0.375rem" + "default": "0.375rem", + "type": "string" }, "space-2-5": { - "type": "string", - "default": "0.625rem" + "default": "0.625rem", + "type": "string" }, "space-3-5": { - "type": "string", - "default": "0.875rem" + "default": "0.875rem", + "type": "string" } - }, - "additionalProperties": false + } }, "markdown": { - "type": "object", "default": {}, + "additionalProperties": false, + "type": "object", "properties": { "sidenote": { - "type": "object", "default": {}, + "additionalProperties": false, + "description": "CSS content values for sidenotes; include CSS string quotes inside the YAML value.", + "type": "object", "properties": { "sidenote-callout-before-content": { - "type": "string", - "default": "\"[ \"" + "default": "\"[ \"", + "type": "string" }, "sidenote-callout-after-content": { - "type": "string", - "default": "\" ]\"" + "default": "\" ]\"", + "type": "string" }, "sidenote-citation-before-content": { - "type": "string", - "default": "\"\"" + "default": "\"\"", + "type": "string" }, "sidenote-citation-after-content": { - "type": "string", - "default": "\":\"" + "default": "\":\"", + "type": "string" } - }, - "additionalProperties": false + } }, "code": { - "type": "object", "default": {}, + "additionalProperties": false, + "type": "object", "properties": { "light": { - "enum": [ - "andromeeda", - "aurora-x", - "ayu-dark", - "catppuccin-frappe", - "catppuccin-latte", - "catppuccin-macchiato", - "catppuccin-mocha", - "dark-plus", - "dracula", - "dracula-soft", - "everforest-dark", - "everforest-light", - "github-dark", - "github-dark-default", - "github-dark-dimmed", - "github-dark-high-contrast", - "github-light", - "github-light-default", - "github-light-high-contrast", - "gruvbox-dark-hard", - "gruvbox-dark-medium", - "gruvbox-dark-soft", - "gruvbox-light-hard", - "gruvbox-light-medium", - "gruvbox-light-soft", - "houston", - "kanagawa-dragon", - "kanagawa-lotus", - "kanagawa-wave", - "laserwave", - "light-plus", - "material-theme", - "material-theme-darker", - "material-theme-lighter", - "material-theme-ocean", - "material-theme-palenight", - "min-dark", - "min-light", - "monokai", - "night-owl", - "nord", - "one-dark-pro", - "one-light", - "plastic", - "poimandres", - "red", - "rose-pine", - "rose-pine-dawn", - "rose-pine-moon", - "slack-dark", - "slack-ochin", - "snazzy-light", - "solarized-dark", - "solarized-light", - "synthwave-84", - "tokyo-night", - "vesper", - "vitesse-black", - "vitesse-dark", - "vitesse-light" - ], - "default": "github-light" + "default": "github-light", + "description": "Shiki theme for light-mode code blocks and both editors.", + "anyOf": [ + { + "const": "andromeeda", + "type": "string" + }, + { + "const": "aurora-x", + "type": "string" + }, + { + "const": "ayu-dark", + "type": "string" + }, + { + "const": "catppuccin-frappe", + "type": "string" + }, + { + "const": "catppuccin-latte", + "type": "string" + }, + { + "const": "catppuccin-macchiato", + "type": "string" + }, + { + "const": "catppuccin-mocha", + "type": "string" + }, + { + "const": "dark-plus", + "type": "string" + }, + { + "const": "dracula", + "type": "string" + }, + { + "const": "dracula-soft", + "type": "string" + }, + { + "const": "everforest-dark", + "type": "string" + }, + { + "const": "everforest-light", + "type": "string" + }, + { + "const": "github-dark", + "type": "string" + }, + { + "const": "github-dark-default", + "type": "string" + }, + { + "const": "github-dark-dimmed", + "type": "string" + }, + { + "const": "github-dark-high-contrast", + "type": "string" + }, + { + "const": "github-light", + "type": "string" + }, + { + "const": "github-light-default", + "type": "string" + }, + { + "const": "github-light-high-contrast", + "type": "string" + }, + { + "const": "gruvbox-dark-hard", + "type": "string" + }, + { + "const": "gruvbox-dark-medium", + "type": "string" + }, + { + "const": "gruvbox-dark-soft", + "type": "string" + }, + { + "const": "gruvbox-light-hard", + "type": "string" + }, + { + "const": "gruvbox-light-medium", + "type": "string" + }, + { + "const": "gruvbox-light-soft", + "type": "string" + }, + { + "const": "houston", + "type": "string" + }, + { + "const": "kanagawa-dragon", + "type": "string" + }, + { + "const": "kanagawa-lotus", + "type": "string" + }, + { + "const": "kanagawa-wave", + "type": "string" + }, + { + "const": "laserwave", + "type": "string" + }, + { + "const": "light-plus", + "type": "string" + }, + { + "const": "material-theme", + "type": "string" + }, + { + "const": "material-theme-darker", + "type": "string" + }, + { + "const": "material-theme-lighter", + "type": "string" + }, + { + "const": "material-theme-ocean", + "type": "string" + }, + { + "const": "material-theme-palenight", + "type": "string" + }, + { + "const": "min-dark", + "type": "string" + }, + { + "const": "min-light", + "type": "string" + }, + { + "const": "monokai", + "type": "string" + }, + { + "const": "night-owl", + "type": "string" + }, + { + "const": "nord", + "type": "string" + }, + { + "const": "one-dark-pro", + "type": "string" + }, + { + "const": "one-light", + "type": "string" + }, + { + "const": "plastic", + "type": "string" + }, + { + "const": "poimandres", + "type": "string" + }, + { + "const": "red", + "type": "string" + }, + { + "const": "rose-pine", + "type": "string" + }, + { + "const": "rose-pine-dawn", + "type": "string" + }, + { + "const": "rose-pine-moon", + "type": "string" + }, + { + "const": "slack-dark", + "type": "string" + }, + { + "const": "slack-ochin", + "type": "string" + }, + { + "const": "snazzy-light", + "type": "string" + }, + { + "const": "solarized-dark", + "type": "string" + }, + { + "const": "solarized-light", + "type": "string" + }, + { + "const": "synthwave-84", + "type": "string" + }, + { + "const": "tokyo-night", + "type": "string" + }, + { + "const": "vesper", + "type": "string" + }, + { + "const": "vitesse-black", + "type": "string" + }, + { + "const": "vitesse-dark", + "type": "string" + }, + { + "const": "vitesse-light", + "type": "string" + } + ] }, "dark": { - "enum": [ - "andromeeda", - "aurora-x", - "ayu-dark", - "catppuccin-frappe", - "catppuccin-latte", - "catppuccin-macchiato", - "catppuccin-mocha", - "dark-plus", - "dracula", - "dracula-soft", - "everforest-dark", - "everforest-light", - "github-dark", - "github-dark-default", - "github-dark-dimmed", - "github-dark-high-contrast", - "github-light", - "github-light-default", - "github-light-high-contrast", - "gruvbox-dark-hard", - "gruvbox-dark-medium", - "gruvbox-dark-soft", - "gruvbox-light-hard", - "gruvbox-light-medium", - "gruvbox-light-soft", - "houston", - "kanagawa-dragon", - "kanagawa-lotus", - "kanagawa-wave", - "laserwave", - "light-plus", - "material-theme", - "material-theme-darker", - "material-theme-lighter", - "material-theme-ocean", - "material-theme-palenight", - "min-dark", - "min-light", - "monokai", - "night-owl", - "nord", - "one-dark-pro", - "one-light", - "plastic", - "poimandres", - "red", - "rose-pine", - "rose-pine-dawn", - "rose-pine-moon", - "slack-dark", - "slack-ochin", - "snazzy-light", - "solarized-dark", - "solarized-light", - "synthwave-84", - "tokyo-night", - "vesper", - "vitesse-black", - "vitesse-dark", - "vitesse-light" - ], - "default": "github-dark" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - }, - "if": { - "type": "object", - "properties": { - "auth": { - "type": "object", - "properties": { - "enabled": { - "const": true - } - } - } - } - }, - "then": { - "type": "object", - "properties": { - "auth": { - "type": "object", - "properties": { - "secret": { - "type": "string", - "minLength": 1 - } - } - } - }, - "if": { - "type": "object", - "properties": { - "auth": { - "type": "object", - "properties": { - "type": { - "const": "credentials" - } - } - } - } - }, - "then": { - "type": "object", - "properties": { - "auth": { - "type": "object", - "required": [ - "credentials" - ], - "properties": { - "credentials": { - "type": "object", - "properties": { - "username": { - "type": "string", - "minLength": 1 - }, - "password": { - "type": "string", - "minLength": 1 + "default": "github-dark", + "description": "Shiki theme for dark-mode code blocks and both editors.", + "anyOf": [ + { + "const": "andromeeda", + "type": "string" + }, + { + "const": "aurora-x", + "type": "string" + }, + { + "const": "ayu-dark", + "type": "string" + }, + { + "const": "catppuccin-frappe", + "type": "string" + }, + { + "const": "catppuccin-latte", + "type": "string" + }, + { + "const": "catppuccin-macchiato", + "type": "string" + }, + { + "const": "catppuccin-mocha", + "type": "string" + }, + { + "const": "dark-plus", + "type": "string" + }, + { + "const": "dracula", + "type": "string" + }, + { + "const": "dracula-soft", + "type": "string" + }, + { + "const": "everforest-dark", + "type": "string" + }, + { + "const": "everforest-light", + "type": "string" + }, + { + "const": "github-dark", + "type": "string" + }, + { + "const": "github-dark-default", + "type": "string" + }, + { + "const": "github-dark-dimmed", + "type": "string" + }, + { + "const": "github-dark-high-contrast", + "type": "string" + }, + { + "const": "github-light", + "type": "string" + }, + { + "const": "github-light-default", + "type": "string" + }, + { + "const": "github-light-high-contrast", + "type": "string" + }, + { + "const": "gruvbox-dark-hard", + "type": "string" + }, + { + "const": "gruvbox-dark-medium", + "type": "string" + }, + { + "const": "gruvbox-dark-soft", + "type": "string" + }, + { + "const": "gruvbox-light-hard", + "type": "string" + }, + { + "const": "gruvbox-light-medium", + "type": "string" + }, + { + "const": "gruvbox-light-soft", + "type": "string" + }, + { + "const": "houston", + "type": "string" + }, + { + "const": "kanagawa-dragon", + "type": "string" + }, + { + "const": "kanagawa-lotus", + "type": "string" + }, + { + "const": "kanagawa-wave", + "type": "string" + }, + { + "const": "laserwave", + "type": "string" + }, + { + "const": "light-plus", + "type": "string" + }, + { + "const": "material-theme", + "type": "string" + }, + { + "const": "material-theme-darker", + "type": "string" + }, + { + "const": "material-theme-lighter", + "type": "string" + }, + { + "const": "material-theme-ocean", + "type": "string" + }, + { + "const": "material-theme-palenight", + "type": "string" + }, + { + "const": "min-dark", + "type": "string" + }, + { + "const": "min-light", + "type": "string" + }, + { + "const": "monokai", + "type": "string" + }, + { + "const": "night-owl", + "type": "string" + }, + { + "const": "nord", + "type": "string" + }, + { + "const": "one-dark-pro", + "type": "string" + }, + { + "const": "one-light", + "type": "string" + }, + { + "const": "plastic", + "type": "string" + }, + { + "const": "poimandres", + "type": "string" + }, + { + "const": "red", + "type": "string" + }, + { + "const": "rose-pine", + "type": "string" + }, + { + "const": "rose-pine-dawn", + "type": "string" + }, + { + "const": "rose-pine-moon", + "type": "string" + }, + { + "const": "slack-dark", + "type": "string" + }, + { + "const": "slack-ochin", + "type": "string" + }, + { + "const": "snazzy-light", + "type": "string" + }, + { + "const": "solarized-dark", + "type": "string" + }, + { + "const": "solarized-light", + "type": "string" + }, + { + "const": "synthwave-84", + "type": "string" + }, + { + "const": "tokyo-night", + "type": "string" + }, + { + "const": "vesper", + "type": "string" + }, + { + "const": "vitesse-black", + "type": "string" + }, + { + "const": "vitesse-dark", + "type": "string" + }, + { + "const": "vitesse-light", + "type": "string" + } + ] } } } @@ -1068,35 +1513,5 @@ } } } - }, - "allOf": [ - { - "if": { - "type": "object", - "properties": { - "ai": { - "type": "object" - } - }, - "required": [ - "ai" - ] - }, - "then": { - "type": "object", - "properties": { - "ai": { - "type": "object", - "properties": { - "api_key": { - "type": "string", - "minLength": 1 - } - } - } - } - } - } - ], - "additionalProperties": false + } } diff --git a/packages/web/config/vitest.config.ts b/packages/web/config/vitest.config.ts index 5f3fd2d8..a53f70b4 100644 --- a/packages/web/config/vitest.config.ts +++ b/packages/web/config/vitest.config.ts @@ -17,10 +17,8 @@ export default defineConfig({ "coverage/**", "eslint.config.js", "vitest.config.ts", - "**/schema.ts", "**/*.d.ts", ], }, }, }); - diff --git a/packages/web/docs/config.md b/packages/web/docs/config.md index c7d6ac72..33638a82 100644 --- a/packages/web/docs/config.md +++ b/packages/web/docs/config.md @@ -1,124 +1,93 @@ # Web Configuration Reference ⚙️ -The Luzzle Web Explorer is configured via `config.yaml`. +The Web Explorer and Worker share `config.yaml`. Omitted settings use defaults. -> [!NOTE] -> The Web ecosystem uses its own specific configuration schema, distinct from -> the CLI configuration. Make sure to specify the schema header at the top of -> your YAML configuration file: -> -> ```yaml -> # yaml-language-server: $schema=../../config/src/lib/config/web.config.schema.json -> ``` +## Editor support ---- +Add a schema header for completion and diagnostics. For `apps/web/demo/config.yaml`: -## Core Settings +```yaml +# yaml-language-server: $schema=../../../packages/web/config/src/lib/config/web.config.schema.json +url: + app: http://localhost:8080 +``` - +The schema path is relative to the YAML file. -| Path | Env Variable | Required | Default | Description | -| ------------------- | ------------------- | -------- | ----------------------- | ----------------------------------------- | -| `url.app` | `LUZZLE_APP_URL` | Yes | `http://localhost:8080` | The public URL of your Explorer. | -| `url.app_assets` | - | Yes | `""` | Base URL for application-specific assets. | -| `url.luzzle_assets` | - | Yes | `""` | Base URL for general Luzzle assets. | -| `paths.database` | - | Yes | `./data/luzzle.sqlite` | Path to the SQLite database. | -| `assets.salt` | `LUZZLE_ASSET_SALT` | Yes | `""` | Salt used to obfuscate asset file paths. | +## Environment references - +- `${VAR}` expands an environment variable; missing variables are errors. +- `${VAR:-fallback}` supplies a value when unset. An empty variable counts as set. +- `$$` escapes a dollar sign, so `$${VAR}` remains literal `${VAR}`. ---- +Expansion applies once to string values. Enum settings such as the AI provider +and code-theme names use their supported literal values. -## Storage 📦 +## URLs, storage and paths - +| Setting | Default | Purpose | +| --- | --- | --- | +| `url.app` | `${LUZZLE_APP_URL:-http://localhost:8080}` | Public site URL | +| `url.app_assets` | `""` | Application asset base URL | +| `url.luzzle_assets` | `""` | Published piece asset base URL | +| `storage.root` | `./archive` | Markdown archive directory | +| `paths.database` | `./data/luzzle.sqlite` | SQLite index | +| `paths.assets` | `./assets/pieces` | Generated piece assets | +| `paths.cache` | `./nginx` | Proxy cache | +| `paths.static` | `./static` | Static files | +| `assets.salt` | `${LUZZLE_ASSET_SALT:-}` | Asset-key salt | -| Path | Env Variable | Required | Default | Description | -| ------------------------- | ------------ | -------- | ------------ | -------------------------------------------------- | -| `storage.type` | - | Yes | `filesystem` | Type of storage backend: `filesystem` or `webdav`. | -| `storage.config.root` | - | Yes | `./archive` | Path to your Markdown archive. | -| `storage.config.url` | - | No | `""` | WebDAV server URL (required if type is `webdav`). | -| `storage.config.username` | - | No | `""` | WebDAV username. | -| `storage.config.password` | - | No | `""` | WebDAV password. | +## Authentication - +Omit `auth` for a public-only site with admin access disabled. Otherwise, provide +a nonempty `secret` and exactly one provider. ---- +Credentials: -## Authentication 🔐 +```yaml +auth: + secret: ${LUZZLE_AUTH_SECRET} + credentials: + username: ${LUZZLE_AUTH_USERNAME} + password: ${LUZZLE_AUTH_PASSWORD} +``` -Luzzle supports OIDC and simple Credentials. +Or OIDC: - +```yaml +auth: + secret: ${LUZZLE_AUTH_SECRET} + oidc: + issuer: ${OIDC_ISSUER} + clientId: ${OIDC_CLIENT_ID} + clientSecret: ${OIDC_CLIENT_SECRET} +``` -| Path | Env Variable | Required | Default | Description | -| --------------------------- | ---------------------- | -------- | ------------------ | ----------------------------------------- | -| `auth.enabled` | - | Yes | `false` | Set to `true` to enable editing features. | -| `auth.secret` | `LUZZLE_AUTH_SECRET` | Yes\* | - | A random secret for session encryption. | -| `auth.type` | - | Yes | `oidc` | `oidc` or `credentials`. | -| `auth.oidc.name` | - | No | `"Single Sign-On"` | Display name for OIDC login provider. | -| `auth.oidc.issuer` | - | No\*\*\* | - | OIDC issuer URL. | -| `auth.oidc.clientId` | - | No\*\*\* | - | OIDC client ID. | -| `auth.oidc.clientSecret` | - | No\*\*\* | - | OIDC client secret. | -| `auth.credentials.username` | `LUZZLE_AUTH_USERNAME` | No\*\* | - | Admin username. | -| `auth.credentials.password` | `LUZZLE_AUTH_PASSWORD` | No\*\* | - | Admin password. | +All shown fields are required and nonempty. OIDC also accepts `name`, which +defaults to `Single Sign-On`. - +## AI generation -\* _Required if `auth.enabled` is `true`._ -\*\* _Required if `auth.type` is `credentials`._ -\*\*\* _Required if `auth.type` is `oidc` and `auth.enabled` is `true`._ +Omit `ai` to disable generation. Both fields are required when configured: ---- +```yaml +ai: + provider: google + api_key: ${GOOGLE_API_KEY} +``` -## Builder 🏗️ +## Other settings -The builder settings define how the Explorer triggers background build processes. +| Section | Purpose | +| --- | --- | +| `network` | Internal service addresses and development networking | +| `worker.queue.path` | Workflow queue database path | +| `sync` | Rclone archive and CDN synchronization | +| `content.text` | Site title and description | +| `content.component` | Custom page components | +| `pieces` | Piece types, frontmatter field mappings and components | +| `theme.globals`, `theme.light`, `theme.dark` | Fonts, spacing and color tokens | +| `theme.markdown` | Sidenotes and Shiki code/editor themes | - - -| Path | Env Variable | Required | Default | Description | -| ---------------- | ---------------------- | -------- | ------- | ----------------------------------------- | -| `builder.url` | `LUZZLE_BUILDER_TOKEN` | Yes | - | The webhook URL to trigger a build. | -| `builder.method` | - | No | `POST` | The HTTP method used to call the webhook. | - - - ---- - - - -## Content & Theme 🎨 - -| Path | Required | Default | Description | -| -------------------------- | -------- | ---------------------------- | ---------------------------------------------------------- | -| `content.text.title` | Yes | `Luzzle Explorer` | The title displayed on your site. | -| `content.text.description` | Yes | `A Luzzle Explorer instance` | The site description for SEO and OG tags. | -| `theme.globals` | No | (Material) | Global design tokens (fonts, spacing, radii, breakpoints). | -| `theme.light` | No | (Material) | Custom colors for light mode. | -| `theme.dark` | No | (Material) | Custom colors for dark mode. | -| `theme.markdown.code` | Yes | - | Shiki themes for code blocks and editor palettes. | - - - ---- - -## AI Features 🤖 - - - -| Path | Env Variable | Required | Default | Description | -| ------------- | ---------------- | -------- | -------- | ---------------------------- | -| `ai.provider` | - | No | `google` | Currently supports `google`. | -| `ai.api_key` | `GOOGLE_API_KEY` | No | - | Your Google Gemini API key. | - - - ---- - -## Pieces 🧩 - -The `pieces` array in `config.yaml` tells Luzzle how to handle different types -of records (maps icons, Open Graph cards, and page layout components to custom -fields). You can learn how to structure these by exploring the sample file in -the [demo/](../explorer/demo/config.yaml) folder. +See the [demo configuration](../../../apps/web/demo/config.yaml) for a complete +example, and the schema's field descriptions for defaults and details. diff --git a/packages/web/docs/deployment.md b/packages/web/docs/deployment.md index 56c26e8d..d063845f 100644 --- a/packages/web/docs/deployment.md +++ b/packages/web/docs/deployment.md @@ -1,74 +1,45 @@ # Luzzle Web Ecosystem Deployment Guide 🏗️ -Deploying the Luzzle web ecosystem allows you to serve your digital garden as a -responsive, public website while providing a private, secure editor to manage -your archive in real time. +Serve your archive using the Docker Compose stack. -## System Architecture 🧩 +## Services -The stack runs via Docker Compose and consists of four main containers: +- **Web Explorer** — SvelteKit application and editor. +- **Worker** — OpenWorkflow jobs for synchronization and asset generation. +- **LSP bridge** — Editor language support over WebSockets. +- **Proxy** — Nginx routing and caching. -1. **Web Explorer (`luzzle-web`)** — The SvelteKit frontend server that - processes read queries from the SQLite database and renders pages. -2. **Web Worker (`luzzle-worker`)** — A sidecar processor that polls Sidequest - and performs heavy background tasks like cloud synchronization and asset - variant generation. -3. **LSP WebSocket Bridge (`luzzle-lsp`)** — Integrates CodeMirror inside the - web editor with the Luzzle LSP server over WebSockets. -4. **Snappy Cache Proxy (`luzzle-proxy`)** — An Nginx container that acts as a - reverse proxy, caching static assets and HTML pages to reduce load on the -SvelteKit application. +## Local demo ---- +From the repository root: -## Getting Started: Demo vs Production 🚀 - -The fastest way to test out the stack is to run the local demo: - -```bash -# From the monorepo root, bring up the dev environment +```sh docker-compose -f apps/web/docker-compose.dev.yml up ``` -### Production Deployment +## Production -In a production environment, you don't need to rebuild the containers locally -unless you want to optimize specific web settings or bundle custom features. +Use the published images with [docker-compose.yml](../../../apps/web/docker-compose.yml). +To build a custom Explorer image: -- **Standard Way:** Run the official Luzzle production containers directly -(refer to [docker-compose.yml](../../../apps/web/docker-compose.yml)). -- **Optimization Way:** Build your own custom containers using the monorepo - target build commands: - -```bash +```sh docker build -f apps/web/Dockerfile --target prod -t my-luzzle-web . ``` ---- +## Configuration -## Environment Variables 🔐 +Configure the site in `config.yaml` and supply any referenced environment +variables through `.env` beside the Compose file. The supplied Compose setup +passes that environment to both the Explorer and Worker. -Configure a `.env` file in the same directory as your `docker-compose.yml` to -supply these credentials: +See the [configuration reference](./config.md) for authentication, AI and +other settings, and the [demo config](../../../apps/web/demo/config.yaml) for +an example. -| Variable | Description -| -| ---------------------- | -------------------------------------------------------------------- | -| `LUZZLE_AUTH_SECRET` | A secure, random string used to encrypt cookie sessions for editing. | -| `LUZZLE_AUTH_USERNAME` | Username for accessing the web editor. | -| `LUZZLE_AUTH_PASSWORD` | Password for accessing the web editor. | -| `GOOGLE_API_KEY` | (Optional) Your Gemini API key for AI metadata generation helper. | +## Cloud sync ---- +Configure rclone remotes in `rclone.conf`, then select them through +`sync.archive` and `sync.cdn` in `config.yaml`. -## Cloud Sync with Rclone ☁️ - -The Web Worker supports syncing your piece archive and static CDN assets to -cloud providers using `rclone`. - -- **Sample Configuration:** Inspect the repository's sample -[rclone.conf](../../../apps/web/demo/rclone/rclone.conf) to see how local remotes are -configured. -- **Official Docs:** For configuring production cloud remotes (like AWS S3, -Cloudflare R2, Google Cloud Storage, or Backblaze B2), check out the [Official -Rclone Documentation](https://rclone.org/docs/). +See the [sample rclone configuration](../../../apps/web/demo/rclone/rclone.conf) +and [rclone documentation](https://rclone.org/docs/). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb2c9767..b0dec364 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -444,9 +444,9 @@ importers: packages/web/config: dependencies: - ajv: - specifier: 8.17.1 - version: 8.17.1 + '@sinclair/typebox': + specifier: 0.34.49 + version: 0.34.49 yaml: specifier: 2.7.0 version: 2.7.0 @@ -475,9 +475,6 @@ importers: globals: specifier: 'catalog:' version: 16.0.0 - json-schema-to-typescript: - specifier: 15.0.4 - version: 15.0.4 prettier: specifier: 'catalog:' version: 3.4.2 @@ -916,10 +913,6 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@apidevtools/json-schema-ref-parser@11.9.3': - resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} - engines: {node: '>= 16'} - '@auth/core@0.41.1': resolution: {integrity: sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==} peerDependencies: @@ -1681,9 +1674,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jsdevtools/ono@7.1.3': - resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} - '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} @@ -2030,6 +2020,9 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -3186,11 +3179,6 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - json-schema-to-typescript@15.0.4: - resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==} - engines: {node: '>=16.0.0'} - hasBin: true - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -3265,9 +3253,6 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -4532,12 +4517,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.2 - '@apidevtools/json-schema-ref-parser@11.9.3': - dependencies: - '@jsdevtools/ono': 7.1.3 - '@types/json-schema': 7.0.15 - js-yaml: 4.1.1 - '@auth/core@0.41.1': dependencies: '@panva/hkdf': 1.2.1 @@ -5334,8 +5313,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@jsdevtools/ono@7.1.3': {} - '@keyv/serialize@1.1.1': {} '@lezer/common@1.5.2': {} @@ -5656,6 +5633,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sinclair/typebox@0.34.49': {} + '@sindresorhus/is@7.2.0': {} '@sindresorhus/slugify@3.0.0': @@ -6904,18 +6883,6 @@ snapshots: json-buffer@3.0.1: {} - json-schema-to-typescript@15.0.4: - dependencies: - '@apidevtools/json-schema-ref-parser': 11.9.3 - '@types/json-schema': 7.0.15 - '@types/lodash': 4.17.24 - is-glob: 4.0.3 - js-yaml: 4.1.1 - lodash: 4.18.1 - minimist: 1.2.8 - prettier: 3.4.2 - tinyglobby: 0.2.16 - json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -6985,8 +6952,6 @@ snapshots: lodash.merge@4.6.2: {} - lodash@4.18.1: {} - long@5.3.2: {} longest-streak@3.1.0: {}