diff --git a/.changeset/public-config-schema.md b/.changeset/public-config-schema.md new file mode 100644 index 00000000..d9fc07fc --- /dev/null +++ b/.changeset/public-config-schema.md @@ -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. diff --git a/apps/web/e2e/schema.e2e.ts b/apps/web/e2e/schema.e2e.ts new file mode 100644 index 00000000..d639bf6b --- /dev/null +++ b/apps/web/e2e/schema.e2e.ts @@ -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); +}); diff --git a/apps/web/src/routes/schema.json/+server.ts b/apps/web/src/routes/schema.json/+server.ts new file mode 100644 index 00000000..ae3dfb2d --- /dev/null +++ b/apps/web/src/routes/schema.json/+server.ts @@ -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' + } + }); +} diff --git a/apps/web/src/routes/schema.json/server.test.ts b/apps/web/src/routes/schema.json/server.test.ts new file mode 100644 index 00000000..a61c57e3 --- /dev/null +++ b/apps/web/src/routes/schema.json/server.test.ts @@ -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(); + }); +}); diff --git a/packages/config/package.json b/packages/config/package.json index 7ceb3c44..7ea58fa5 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -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": [ diff --git a/packages/config/src/schema-export.test.ts b/packages/config/src/schema-export.test.ts new file mode 100644 index 00000000..ea0aa3aa --- /dev/null +++ b/packages/config/src/schema-export.test.ts @@ -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(/^#/); + }); +});