Adopt TypeBox for web configuration #107

Merged
eleith merged 6 commits from feat/typebox-web-config into main 2026-10-05 21:18:24 +00:00
49 changed files with 4007 additions and 3549 deletions

No files matched your search

+15
View File
@@ -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.
+250
View File
@@ -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<void> | undefined;
let connection: MessageConnection | undefined;
type Diagnostic = { message: string };
const pending = new Map<string, (diagnostics: Diagnostic[]) => 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<Diagnostic[]>((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<Diagnostic[]>((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,
);
});
-2
View File
@@ -9,8 +9,6 @@ network:
worker: http://luzzle-dev-worker:9000
auth:
enabled: true
type: credentials
secret: ${LUZZLE_AUTH_SECRET}
credentials:
username: admin
+1 -1
View File
@@ -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" }
+65 -31
View File
@@ -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<Handle>[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 })]
})
)
})
+21 -23
View File
@@ -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
+21
View File
@@ -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> = {}): Config {
return { ...loadConfig(), auth: undefined, ai: undefined, ...overrides }
}
export const credentialsAuth = {
secret: 'test-session-secret',
credentials: { username: 'admin', password: 'test-password' }
} satisfies NonNullable<Config['auth']>
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<Config['auth']>
+15 -2
View File
@@ -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<Uint8Array>
const req = new Request('http://localhost/api/admin/generate', {
+1
View File
@@ -25,6 +25,7 @@ let active = 0
export async function generateResponse(request: Request): Promise<Response> {
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.
+28 -28
View File
@@ -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> = {}): 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 })
+8 -6
View File
@@ -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
}
}
@@ -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()
})
})
@@ -3,9 +3,10 @@ import type { AppConfig } from '$lib/server/config'
import type { ProbeResult } from './probes.js'
export async function validateAiKeyIfConfigured(config: AppConfig): Promise<ProbeResult> {
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' }
}
@@ -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(),
+25 -13
View File
@@ -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()
+2 -7
View File
@@ -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<AppConfig['auth']['type'], string> = {
oidc: 'oidc',
credentials: 'local'
}
export interface DashboardView {
meta: { title: string }
pieceStats: PieceStats
@@ -53,7 +48,7 @@ export async function loadDashboardPage(): Promise<DashboardView> {
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)
}
+42 -71
View File
@@ -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<typeof Health>('../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()
}
)
})
+1 -1
View File
@@ -21,7 +21,7 @@ export function probeWorkerIfConfigured(appConfig: AppConfig): Promise<ProbeResu
}
export function probeOidcIssuerIfConfigured(appConfig: AppConfig): Promise<ProbeResult | null> {
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)
}
+1 -1
View File
@@ -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
@@ -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,
@@ -92,7 +92,7 @@
</div>
<div class="info-row">
<dt>type</dt>
<dd>{data.configSummary.auth.type}</dd>
<dd>{data.configSummary.auth.type ?? 'none'}</dd>
</div>
{#if data.configSummary.auth.issuer}
<div class="info-row">
@@ -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<typeof Core>()),
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
})
}
)
@@ -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<typeof Core>()),
@@ -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?.()
@@ -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<typeof GET>[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' })
})
+27
View File
@@ -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' }
})
})
+5 -2
View File
@@ -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
}
}
@@ -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<typeof load>[0]
}
test('public-only signin redirects before accessing missing Auth.js locals', async () => {
config.auth = undefined
await expect(load({ locals: {} } as Parameters<typeof load>[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'
})
})
+7 -4
View File
@@ -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.
+6 -7
View File
@@ -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"
@@ -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));
+90 -26
View File
@@ -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<Config>();
});
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<string>();
expectTypeOf(config.network.public.host).toEqualTypeOf<string>();
expectTypeOf(config.network.public.hmr_port).toEqualTypeOf<
number | undefined
>();
expectTypeOf(config.sync.archive.remote).toEqualTypeOf<string>();
expectTypeOf(config.sync.cdn.flags).toEqualTypeOf<string[]>();
expectTypeOf(config.sync.cdn.strategy).toEqualTypeOf<"sync" | "copy">();
expectTypeOf(config.worker.queue.path).toEqualTypeOf<string>();
expectTypeOf(config.theme.light["color-primary"]).toEqualTypeOf<string>();
expectTypeOf(
config.theme.globals["font-size-root"],
).toEqualTypeOf<number>();
expectTypeOf<Config["ai"]>().toEqualTypeOf<
{ provider: "google"; api_key: string } | undefined
>();
expectTypeOf<Config["auth"]>().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<keyof ConfigPublic>().toEqualTypeOf<"url" | "content">();
expectTypeOf<keyof ConfigPublic["url"]>().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();
});
});
+5 -1
View File
@@ -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,
};
};
File diff suppressed because it is too large. Load diff
+47 -73
View File
@@ -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<Config['url'], 'app' | 'luzzle_assets' | 'app_assets'>
content: Config['content']
}
url: Pick<Config["url"], "app" | "luzzle_assets" | "app_assets">;
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<string, unknown> = {}
for (const key in obj) {
result[key] = replaceEnvVars((obj as Record<string, unknown>)[key])
}
return result
}
return obj
return config;
}
function loadConfig(userConfigPath?: string): Config {
let config: Partial<Config> = {}
let raw: unknown = {};
if (userConfigPath && existsSync(userConfigPath)) {
config = (yamlParse(readFileSync(userConfigPath, 'utf8')) as Partial<Config>) || {}
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<string, unknown>
if (acc && typeof acc === "object" && key in acc) {
return acc[key] as Record<string, unknown>;
}
return undefined
return undefined;
},
obj as unknown as undefined | Record<string, unknown>
)
obj as unknown as undefined | Record<string, unknown>,
);
}
function setConfigValue(obj: Config, path: string, value: unknown): void {
const keys = path.split('.')
const lastKey = keys.pop()!
let current: Record<string, unknown> = obj as unknown as Record<string, unknown>
const keys = path.split(".");
const lastKey = keys.pop()!;
let current: Record<string, unknown> = 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<string, unknown>
current = current[key] as Record<string, unknown>;
}
current[lastKey] = value
current[lastKey] = value;
}
export { loadConfig, getConfigValue, setConfigValue, type Config }
export { loadConfig, getConfigValue, setConfigValue, type Config };
@@ -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" },
]);
});
});
@@ -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<ValueError>): ConfigIssue[] {
const issues: ConfigIssue[] = [];
for (const error of errors) {
let details: Iterable<ValueError> = [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;
}
}
@@ -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();
});
});
@@ -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;
}
@@ -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);
}
});
});
@@ -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<string, Set<string>>();
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<string>();
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;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-2
View File
@@ -17,10 +17,8 @@ export default defineConfig({
"coverage/**",
"eslint.config.js",
"vitest.config.ts",
"**/schema.ts",
"**/*.d.ts",
],
},
},
});
+69 -100
View File
@@ -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
```
<!-- markdownlint-disable MD013 -->
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
<!-- markdownlint-enable MD013 -->
- `${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
<!-- markdownlint-disable MD013 -->
| 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
<!-- markdownlint-enable MD013 -->
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:
<!-- markdownlint-disable MD013 -->
```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`.
<!-- markdownlint-enable MD013 -->
## 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 |
<!-- markdownlint-disable MD013 -->
| 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. |
<!-- markdownlint-enable MD013 -->
---
<!-- markdownlint-disable MD013 -->
## 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. |
<!-- markdownlint-enable MD013 -->
---
## AI Features 🤖
<!-- markdownlint-disable MD013 -->
| 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. |
<!-- markdownlint-enable MD013 -->
---
## 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.
+25 -54
View File
@@ -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/).
+8 -43
View File
@@ -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: {}