feat(web): serve public configuration schema #92

Merged
eleith merged 1 commits from feat/public-config-schema into main 2026-10-25 12:40:58 +00:00
6 changed files with 126 additions and 0 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
'@when/web': patch
'@when/worker': patch
'@when/cli': patch
---
Serve the generated editor configuration schema publicly at /schema.json from the deployed package. Require cache revalidation and expose only the schema, never loaded configuration or credentials.
+34
View File
@@ -0,0 +1,34 @@
import { expect, test } from '@playwright/test';
import { signInAsAdmin } from './support/auth.ts';
test('schema.json is public, production-packaged, and independent of instance configuration', async ({
page,
request
}) => {
const anonymous = await request.get('/schema.json');
expect(anonymous.status()).toBe(200);
expect(anonymous.headers()['content-type']).toBe('application/schema+json; charset=utf-8');
expect(anonymous.headers()['cache-control']).toBe('public, max-age=0, must-revalidate');
expect(anonymous.headers().location).toBeUndefined();
const text = await anonymous.text();
const schema = JSON.parse(text);
expect(schema.title).toBe('When configuration');
expect(schema.$schema).toBe('https://json-schema.org/draft/2020-12/schema');
expect(schema.$defs.Meeting.required).toContain('title');
expect(schema.$defs.Meeting.required).not.toContain('duration_minutes');
for (const instanceValue of [
'E2E Fixture',
'e2e-admin-password',
'e2e-smtp-password',
'e2e-caldav-password',
'e2e-video-password'
]) {
expect(text).not.toContain(instanceValue);
}
await signInAsAdmin(page);
const signedIn = await page.request.get('/schema.json?include=config&role=host');
expect(signedIn.status()).toBe(200);
const signedInSchema = await signedIn.json();
expect(signedInSchema).toEqual(schema);
});
@@ -0,0 +1,11 @@
import { json } from '@sveltejs/kit';
import editorSchema from '@when/config/schema.json' with { type: 'json' };
export function GET(): Response {
return json(editorSchema, {
headers: {
'content-type': 'application/schema+json; charset=utf-8',
'cache-control': 'public, max-age=0, must-revalidate'
}
});
}
@@ -0,0 +1,32 @@
import { describe, expect, test, vi } from 'vitest';
import editorSchema from '@when/config/schema.json' with { type: 'json' };
import { GET } from './+server';
const getConfig = vi.hoisted(() =>
vi.fn(() => {
throw new Error('Schema must not read instance configuration');
})
);
vi.mock('$lib/server/state', () => ({ getConfig }));
describe('GET /schema.json', () => {
test('returns only the bundled editor schema with the schema media type', async () => {
const response = GET();
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('application/schema+json; charset=utf-8');
expect(response.headers.get('cache-control')).toBe('public, max-age=0, must-revalidate');
const body = await response.json();
expect(body).toEqual(editorSchema);
expect(getConfig).not.toHaveBeenCalled();
});
test('does not let a response consumer mutate the bundled schema for later requests', async () => {
const response = GET();
const body = await response.json();
body.title = 'Not the schema';
const next = GET();
const nextBody = await next.json();
expect(nextBody).toEqual(editorSchema);
expect(getConfig).not.toHaveBeenCalled();
});
});
+4
View File
@@ -8,6 +8,10 @@
"development": "./src/index.ts",
"types": "./dist/src/index.d.ts",
"import": "./dist/src/index.js"
},
"./schema.json": {
"development": "./src/config.schema.json",
"default": "./dist/src/config.schema.json"
}
},
"files": [
+38
View File
@@ -0,0 +1,38 @@
import { readFileSync } from 'node:fs';
import { describe, expect, test } from 'vitest';
import generatedSchema from './config.schema.json' with { type: 'json' };
function references(value: unknown): string[] {
if (value === null || typeof value !== 'object') return [];
const refs: string[] = [];
if ('$ref' in value && typeof value.$ref === 'string') refs.push(value.$ref);
for (const child of Object.values(value)) refs.push(...references(child));
return refs;
}
describe('public editor schema asset', () => {
test('exports the generated schema and includes the same asset in the compiled package', async () => {
// The production export is available after build, not during source typechecking.
const schemaPath = '@when/config/schema.json';
const exported = await import(schemaPath, { with: { type: 'json' } });
expect(exported.default).toEqual(generatedSchema);
const compiledText = readFileSync(
new URL('../dist/src/config.schema.json', import.meta.url),
'utf8'
);
const compiled = JSON.parse(compiledText);
expect(compiled).toEqual(generatedSchema);
});
test('retains editor-friendly optional defaults and self-contained references', () => {
expect(generatedSchema.$schema).toBe('https://json-schema.org/draft/2020-12/schema');
expect(generatedSchema.properties.version.default).toBe(1);
expect(generatedSchema.required).not.toContain('version');
expect(generatedSchema.required).not.toContain('providers');
expect(generatedSchema.$defs.Meeting.required).toContain('title');
expect(generatedSchema.$defs.Meeting.required).not.toContain('duration_minutes');
const refs = references(generatedSchema);
expect(refs.length).toBeGreaterThan(0);
for (const reference of refs) expect(reference).toMatch(/^#/);
});
});