Adopt TypeBox for web configuration #107
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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" }
|
||||
@@ -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 })]
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
@@ -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,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', {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+14
-4
@@ -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
-1
@@ -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(),
|
||||
File renamed without changes.
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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' }
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -17,10 +17,8 @@ export default defineConfig({
|
||||
"coverage/**",
|
||||
"eslint.config.js",
|
||||
"vitest.config.ts",
|
||||
"**/schema.ts",
|
||||
"**/*.d.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+69
-100
@@ -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.
|
||||
@@ -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/).
|
||||
Generated
+8
-43
@@ -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: {}
|
||||
|
||||
Reference in new issue
Block a user