feat(config): make Docker onboarding YAML-first #114

Merged
eleith merged 1 commits from feat/yaml-first-onboarding into main 2026-11-02 03:11:37 +00:00
27 changed files with 471 additions and 222 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
---
'@when/web': patch
'@when/cli': patch
---
Make first-time setup YAML-first without requiring an environment file. Accept an optional `auth.secret`, preserve existing `AUTH_SECRET` overrides, and otherwise generate a secure session key at web startup; restarting signs users out unless a stable key is configured.
Align Compose, configuration examples, CLI starter and setup documentation around existing service/path defaults, optional interpolation and advanced per-service overrides. Leave required passwords blank rather than shipping a shared admin login, create CLI starters with private permissions, and keep inline credentials out of YAML syntax diagnostics.
+11 -7
View File
@@ -13,7 +13,7 @@ No teams. No round-robin routing. No subscription to your own calendar.
## Get started
You'll need Docker Compose. In a fresh directory:
You'll need Docker Compose 2.24 or newer. In a fresh directory:
```sh
mkdir when
@@ -21,20 +21,24 @@ cd when
mkdir config data public
curl -fsSLo compose.yaml https://git.eleith.com/eleith/when/raw/branch/main/docs/compose.yaml
curl -fsSLo config/when.yaml https://git.eleith.com/eleith/when/raw/branch/main/docs/when.example.yaml
curl -fsSLo .env https://git.eleith.com/eleith/when/raw/branch/main/docs/.env.example
openssl rand -base64 32
chmod 600 config/when.yaml
```
Edit `.env` and `config/when.yaml` to make it all cozy and comfortable.
Set `ORIGIN` to your public HTTPS URL and put a trusted reverse proxy in front of
When; see the [proxy requirements](docs/deployment.md#reverse-proxy).
Make `data/` writable by container UID 1000; see [permissions](docs/deployment.md#start-up).
Edit `config/when.yaml`: your public HTTPS URL (`url.app`), admin password,
owner details, SMTP account and calendar. No `.env` file is required.
Put a trusted reverse proxy in front of When; see the
[proxy requirements](docs/deployment.md#reverse-proxy). Keep your config private
and readable by container UID 1000; make `data/` writable by that UID. See
[permissions](docs/deployment.md#start-up).
```sh
docker compose up -d
```
Open your public proxy URL and its `/admin` page to get a lay of the land.
Optional environment interpolation, port overrides and persistent sessions are
covered in [deployment](docs/deployment.md#environment-variables).
There is a lot more going on under the hood, read more documentation if you
want to learn more.
+31 -4
View File
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { join } from 'node:path';
import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'node:fs';
import { writeFileSync, readFileSync, statSync, existsSync, unlinkSync } from 'node:fs';
import { parse as parseYaml } from 'yaml';
import { validateStructure } from '@when/config';
import { validateConfig, validateStructure } from '@when/config';
import { initCommand } from './init.ts';
describe('config init', () => {
@@ -23,6 +23,7 @@ describe('config init', () => {
logSpy.mockRestore();
errorSpy.mockRestore();
process.exitCode = originalExitCode;
vi.unstubAllEnvs();
if (existsSync(path)) unlinkSync(path);
});
@@ -34,12 +35,38 @@ describe('config init', () => {
} as unknown as Parameters<NonNullable<typeof initCommand.run>>[0];
}
test('writes a skeleton that passes structural validation', async () => {
test('writes a YAML-first starter that validates after its blank passwords are filled', async () => {
vi.stubEnv('WHEN_ADMIN_PASSWORD', undefined);
vi.stubEnv('WHEN_SMTP_PASSWORD', undefined);
vi.stubEnv('WHEN_PROVIDER_MY_CALDAV_PASSWORD', undefined);
await initCommand.run!(ctx());
expect(process.exitCode).toBeUndefined();
expect(existsSync(path)).toBe(true);
expect(statSync(path).mode & 0o777).toBe(0o600);
const content = readFileSync(path, 'utf8');
expect(() => validateStructure(parseYaml(content))).not.toThrow();
const raw = parseYaml(content);
expect(content).not.toContain('${');
expect(raw.auth.credentials).toEqual({ username: 'admin', password: '' });
expect(raw.auth.secret).toBeUndefined();
expect(raw.smtp.password).toBe('');
expect(raw.providers['my-caldav'].password).toBe('');
expect(() => validateStructure(raw)).toThrow('schema validation');
raw.auth.credentials.password = 'test-only-admin-password';
raw.smtp.password = 'test-only-smtp-password';
raw.providers['my-caldav'].password = 'test-only-caldav-password';
const config = validateConfig(raw);
expect(config.database).toEqual({
app: './data/when.sqlite',
queue: './data/openworkflow.sqlite'
});
expect(config.schedules.standard.weekly).toEqual([
{ days: ['mon', 'tue', 'wed', 'thu', 'fri'], from: '09:00', to: '17:00' }
]);
expect(config.smtp.port).toBe(587);
expect(config.meetings['30-min'].duration_minutes).toBe(30);
expect(config.url.app).toBe('https://book.example.com');
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('config validate'));
});
+15 -17
View File
@@ -6,47 +6,43 @@ import { pass, fail } from '../../utils/report.ts';
const SKELETON = `# yaml-language-server: $schema=http://localhost:3000/schema.json
# Adjust the schema URL to your running app's address.
# Edit the placeholders and set the \${...} env vars, then run: when-cli config validate.
# Edit the example values and fill all three blank passwords below, then run:
# when-cli config validate
# No .env file is required. Environment interpolation is an advanced option.
# Keep this file private: it contains your passwords.
# Full example: https://git.eleith.com/eleith/when/raw/branch/main/docs/when.example.yaml
auth:
# Optional auth.secret: a random string of at least 32 characters.
# Omit it for a random secret each startup; restarts will sign you out.
credentials:
username: admin
password: \${WHEN_ADMIN_PASSWORD}
password: '' # Required: choose your own admin password.
user:
name: Your Name
email: you@example.com
timezone: UTC
smtp:
host: smtp.example.com
port: 587
username: smtp-user
password: \${WHEN_SMTP_PASSWORD}
password: '' # Required: your SMTP password.
providers:
my-caldav:
type: caldav
url: https://dav.example.com/
username: dav-user
password: \${WHEN_PROVIDER_MY_CALDAV_PASSWORD}
password: '' # Required: your CalDAV password (or app password).
calendars:
primary:
href: calendars/dav-user/primary/
schedules:
standard:
weekly:
- days: [mon, tue, wed, thu, fri]
from: '09:00'
to: '17:00'
standard: {} # Defaults to Monday-Friday, 09:00-17:00 in user.timezone.
meetings:
30-min:
title: 30 minute meeting
duration_minutes: 30
booking_calendar: primary
schedule: standard
database:
app: ./data/when.sqlite
queue: ./data/openworkflow.sqlite
url:
app: https://book.example.com
app: https://book.example.com # Your public HTTPS URL behind a trusted reverse proxy.
`;
export const initCommand = define({
@@ -67,10 +63,12 @@ export const initCommand = define({
}
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, SKELETON);
writeFileSync(path, SKELETON, { mode: 0o600, flag: 'wx' });
pass(`wrote ${path}`);
console.log(' edit the placeholders, then run: when-cli config validate');
console.log(
' edit the example values and fill all three blank passwords, then run: when-cli config validate'
);
console.log(
' see https://git.eleith.com/eleith/when/src/branch/main/docs/when.example.yaml for every option'
);
@@ -124,6 +124,7 @@ url:
logSpy.mockRestore();
errorSpy.mockRestore();
process.exitCode = originalExitCode;
vi.unstubAllEnvs();
// Clean up files
try {
@@ -248,6 +249,27 @@ url:
);
});
test('checks resolved session-secret length only with --check-env and redacts the value', async () => {
const variable = 'WHEN_TEST_SESSION_SECRET';
vi.stubEnv(variable, undefined);
writeFileSync(
tempValidPath,
validConfigYaml.replace('auth:\n', 'auth:\n secret: "${WHEN_TEST_SESSION_SECRET}"\n')
);
const ctx = {
positionals: ['config', 'validate', tempValidPath],
commandPath: ['config', 'validate']
} as unknown as Parameters<NonNullable<typeof validateCommand.run>>[0];
await validateCommand.run!(ctx);
expect(process.exitCode).toBeUndefined();
const secret = 'private-short-session-key';
vi.stubEnv(variable, secret);
await validateCommand.run!({ ...ctx, values: { 'check-env': true } });
expect(process.exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('at least 32 characters'));
expect(errorSpy.mock.calls.flat().join('\n')).not.toContain(secret);
});
test('fails if specified file does not exist', async () => {
const nonExistentPath = join(process.cwd(), 'does-not-exist.yaml');
const ctx = {
+9
View File
@@ -1,5 +1,8 @@
services:
when-app:
env_file:
- path: .env
required: false
build:
context: ../..
dockerfile: apps/web/Dockerfile
@@ -43,6 +46,9 @@ services:
retries: 3
when-worker:
env_file:
- path: .env
required: false
build:
context: ../..
dockerfile: apps/worker/Dockerfile
@@ -77,6 +83,9 @@ services:
- ../../packages/video-chat/src:/m/packages/video-chat/src
when-cli:
env_file:
- path: .env
required: false
profiles:
- tools
build:
+12 -1
View File
@@ -1,11 +1,16 @@
services:
when-app:
env_file:
- path: .env
required: false
environment:
PROTOCOL_HEADER: x-forwarded-proto
build:
context: ../..
dockerfile: apps/web/Dockerfile
target: prod
ports:
- '3000:3000'
- '127.0.0.1:${WHEN_HTTP_PORT:-3000}:3000'
volumes:
- ./config:/app/config
- ./data:/app/data
@@ -28,6 +33,9 @@ services:
retries: 3
when-worker:
env_file:
- path: .env
required: false
build:
context: ../..
dockerfile: apps/worker/Dockerfile
@@ -50,6 +58,9 @@ services:
retries: 3
when-cli:
env_file:
- path: .env
required: false
build:
context: ../..
dockerfile: apps/cli/Dockerfile
@@ -25,6 +25,7 @@ describe('createAppointmentFlow duration', () => {
expect(flow.duration).toBe(30);
expect(flow.durations).toEqual([30, 60]);
expect(flow.allSlots).toEqual([SLOT_10, SLOT_1030]);
expect(flow.originalStartUnavailable).toBe(false);
});
it('switches the live slot map when the length changes', () => {
@@ -123,6 +124,7 @@ describe('createAppointmentFlow rescheduling', () => {
expect(flow.step).toBe(1);
expect(flow.viewDate).toBe('2026-07-20');
expect(flow.selectedSlot).toBe(SLOT_10);
expect(flow.originalStartUnavailable).toBe(false);
expect(flow.hasChanges).toBe(false);
expect(flow.canAdvance).toBe(true);
expect(flow.canSubmit).toBe(false);
@@ -184,6 +186,7 @@ describe('createAppointmentFlow rescheduling', () => {
expect(flow.viewDate).toBe('2026-07-21');
expect(flow.selectedSlot).toBe(original);
expect(flow.allSlots).not.toContain(original);
expect(flow.originalStartUnavailable).toBe(true);
expect(flow.hasChanges).toBe(false);
expect(flow.canAdvance).toBe(true);
expect(flow.canSubmit).toBe(false);
@@ -196,6 +199,7 @@ describe('createAppointmentFlow rescheduling', () => {
flow.goBack();
flow.setDuration(30);
expect(flow.selectedSlot).toBe(original);
expect(flow.originalStartUnavailable).toBe(false);
expect(flow.hasChanges).toBe(true);
expect(flow.canSubmit).toBe(true);
flow.goBack();
@@ -203,6 +207,7 @@ describe('createAppointmentFlow rescheduling', () => {
flow.selectDate('2026-07-22');
expect(flow.viewDate).toBe('2026-07-22');
expect(flow.selectedSlot).toBeNull();
expect(flow.originalStartUnavailable).toBe(false);
expect(flow.hasChanges).toBe(true);
expect(flow.canAdvance).toBe(true);
expect(flow.canSubmit).toBe(false);
@@ -19,6 +19,7 @@ export interface AppointmentFlow {
readonly availableDates: Set<string>;
readonly offeredDates: Set<string>;
readonly hasChanges: boolean;
readonly originalStartUnavailable: boolean;
readonly canSubmit: boolean;
readonly canAdvance: boolean;
advance(): void;
@@ -77,6 +78,9 @@ export function createAppointmentFlow({
const dates = $derived(datesFromSlots(allSlots, userTz));
const offeredDates = $derived(dateKeys(slotsByDate));
const originalDate = $derived(originalSlot ? instantToDateKey(originalSlot, userTz) : null);
const originalStartUnavailable = $derived(
originalSlot !== null && selectedSlot === originalSlot && !allSlots.includes(originalSlot)
);
const intervalChanged = $derived(
!reschedule || selectedSlot !== originalSlot || duration !== reschedule.duration
);
@@ -135,6 +139,9 @@ export function createAppointmentFlow({
get hasChanges() {
return hasChanges;
},
get originalStartUnavailable() {
return originalStartUnavailable;
},
get canSubmit() {
return canSubmit;
},
@@ -1,6 +1,5 @@
<script lang="ts">
import { page } from '$app/state';
import * as InstantFns from 'temporal-polyfill/fns/Instant';
import DatePicker from '#lib/components/DatePicker.svelte';
import DayTimeline from '#lib/components/DayTimeline.svelte';
import WizardPickerControls from '#lib/components/WizardPickerControls.svelte';
@@ -86,15 +85,6 @@
let userTz = $derived(flow.userTz);
let isGuestFormValid = $state(false);
const originalStart = $derived.by(() => {
if (!rescheduleAppt) return null;
const start = InstantFns.fromString(rescheduleAppt.start_time);
return InstantFns.toString(start);
});
const originalStartAvailable = $derived(
originalStart !== null && flow.allSlots.includes(originalStart)
);
let previousAppointmentHref = $derived(
rescheduleAppt
? `/appointment/${rescheduleAppt.id}?token=${encodeURIComponent(rescheduleToken || '')}`
@@ -222,7 +212,7 @@
<p class="reschedule-notice" role="status">
Choose an available start time for this duration.
</p>
{:else if rescheduleAppt && !originalStartAvailable && selectedSlot === originalStart}
{:else if flow.originalStartUnavailable}
<p class="reschedule-notice" role="status">
The current start time isn't available for this duration. Choose another time or
duration.
+31 -1
View File
@@ -8,10 +8,10 @@ import { authHandle, getAuth, makeAuth } from './index';
// Exercise the actual production limiter and origin checks, not test-mode defaults.
vi.hoisted(() => {
vi.stubEnv('NODE_ENV', 'production');
vi.stubEnv('AUTH_SECRET', 'only-a-test-secret-with-at-least-32-characters');
});
const origin = 'https://when.example.test';
const testSecret = 'only-a-test-secret-with-at-least-32-characters';
const cfg = {
...validConfig,
auth: {
@@ -25,6 +25,7 @@ let client = 0;
let address: string;
beforeEach(() => {
vi.stubEnv('AUTH_SECRET', testSecret);
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-29T12:00:00Z'));
address = `192.0.2.${++client}`;
@@ -147,6 +148,35 @@ test('cookies survive a fresh instance, expose only app fields and expire at the
expect(await appSession(cookie)).toBeNull();
});
test('a YAML secret keeps sessions across restarts without AUTH_SECRET', async () => {
const yamlConfig = {
...cfg,
auth: { ...cfg.auth, secret: 'yaml-secret-with-at-least-32-characters' }
};
vi.stubEnv('AUTH_SECRET', 'too-short');
makeAuth(yamlConfig);
const login = await request('/sign-in/credentials', credentials);
expect(login.status).toBe(200);
const cookie = cookies(login);
const session = await appSession(cookie);
vi.stubEnv('AUTH_SECRET', undefined);
makeAuth(yamlConfig);
expect(await appSession(cookie)).toEqual(session);
expect(session.user.name).toBe(credentials.username);
});
test('generated secrets work within an instance and sign out after a restart', async () => {
vi.stubEnv('AUTH_SECRET', undefined);
makeAuth(cfg);
const login = await request('/sign-in/credentials', credentials);
expect(login.status).toBe(200);
const cookie = cookies(login);
expect((await appSession(cookie)).user.name).toBe(credentials.username);
makeAuth(cfg);
expect(await appSession(cookie)).toBeNull();
expect((await request('/sign-in/credentials', credentials)).status).toBe(200);
});
test('bypassing the cache cannot renew the underlying session past seven days', async () => {
const login = await request('/sign-in/credentials', credentials);
const cookie = cookies(login);
+2 -4
View File
@@ -6,9 +6,7 @@ import type { WhenConfiguration } from '@when/config';
import { logger } from '../logger';
import { loginMetricsHooks } from '../metrics/auth';
import { buildAuthOptions } from './providers';
import { requireAuthSecret } from './secret';
export { requireAuthSecret } from './secret';
import { resolveAuthSecret } from './secret';
const WEEK_SECONDS = 7 * 24 * 60 * 60;
@@ -21,7 +19,7 @@ let instance: ReturnType<typeof betterAuth> | null = null;
export function makeAuth(cfg: WhenConfiguration) {
instance = betterAuth<BetterAuthOptions>({
secret: requireAuthSecret(),
secret: resolveAuthSecret(cfg.auth.secret),
baseURL: cfg.url.app.replace(/\/+$/, ''),
basePath: '/auth',
telemetry: { enabled: false },
+15 -8
View File
@@ -1,6 +1,6 @@
import { expect, test } from 'vitest';
import { buildAuthOptions, verifyCredentials } from './providers';
import { requireAuthSecret } from '#lib/server/auth/secret.js';
import { resolveAuthSecret } from '#lib/server/auth/secret.js';
import { validConfig } from '#lib/server/__fixtures__/valid-config.js';
import type { WhenConfiguration } from '@when/config';
@@ -26,17 +26,24 @@ test('oidc config registers only the generic OAuth plugin', () => {
expect(options.plugins?.map((plugin) => plugin.id)).toEqual(['generic-oauth']);
});
test('requireAuthSecret rejects missing secret', () => {
expect(() => requireAuthSecret({})).toThrow(/AUTH_SECRET/);
test('an omitted secret generates a fresh 32-byte key', () => {
const secret = resolveAuthSecret(undefined, {});
expect(Buffer.from(secret, 'base64')).toHaveLength(32);
expect(resolveAuthSecret(undefined, {})).not.toBe(secret);
});
test('requireAuthSecret rejects short secret', () => {
expect(() => requireAuthSecret({ AUTH_SECRET: 'too-short' })).toThrow();
});
test.for(['', 'too-short', 'a'.repeat(31)])(
'a configured short or empty secret fails closed: %s',
(secret) => {
expect(() => resolveAuthSecret(secret, { AUTH_SECRET: 'b'.repeat(32) })).toThrow(/at least 32/);
expect(() => resolveAuthSecret(undefined, { AUTH_SECRET: secret })).toThrow(/at least 32/);
}
);
test('requireAuthSecret accepts a 32+ char secret', () => {
test('YAML takes precedence over the existing environment secret', () => {
const secret = 'a'.repeat(32);
expect(requireAuthSecret({ AUTH_SECRET: secret })).toBe(secret);
expect(resolveAuthSecret(secret, { AUTH_SECRET: 'too-short' })).toBe(secret);
expect(resolveAuthSecret(undefined, { AUTH_SECRET: secret })).toBe(secret);
});
const ADMIN = { username: 'admin', password: 'correct horse' };
+10 -6
View File
@@ -1,9 +1,13 @@
export function requireAuthSecret(env: NodeJS.ProcessEnv = process.env): string {
const secret = env.AUTH_SECRET;
if (!secret || secret.length < 32) {
throw new Error(
'AUTH_SECRET env var is required and must be at least 32 characters (generate with `openssl rand -base64 32`)'
);
import { randomBytes } from 'node:crypto';
export function resolveAuthSecret(
configured?: string,
env: NodeJS.ProcessEnv = process.env
): string {
const secret = configured ?? env.AUTH_SECRET;
if (secret === undefined) return randomBytes(32).toString('base64');
if (secret.length < 32) {
throw new Error('auth.secret or AUTH_SECRET must contain at least 32 characters');
}
return secret;
}
+1 -3
View File
@@ -1,5 +1,4 @@
import { makeAuth } from './auth';
import { requireAuthSecret } from './auth/secret';
import { getState, setState } from './state';
import { bootConfig } from './config/boot';
import { applyConfig } from './config/reload';
@@ -11,13 +10,12 @@ import { logger } from './logger';
export async function bootApp(): Promise<void> {
setLogger(logger);
requireAuthSecret();
const config = await bootConfig();
makeAuth(config);
const db = openDb(config.database.app);
const applied = await runMigrations(db);
if (applied.length > 0) logger.info({ migrations: applied }, 'migrations applied');
initOpenWorkflow({ dbPath: config.database.queue });
makeAuth(config);
setState({ config, db });
const stopWatching = watchConfig((result) =>
+13 -2
View File
@@ -37,8 +37,19 @@ function allSchemaFieldNames(): Set<string> {
}
describe('docs/when.example.yaml', () => {
test('is a structurally valid config', () => {
expect(() => config.validateStructure(parseYaml(exampleText))).not.toThrow();
test('requires real passwords and validates without an environment file once filled', () => {
const raw = parseYaml(exampleText);
expect(raw.auth.credentials.password).toBe('');
expect(raw.smtp.password).toBe('');
expect(raw.providers['caldav-service'].password).toBe('');
expect(() => config.validateStructure(raw)).toThrow(config.ConfigError);
raw.auth.credentials.password = 'test-only-admin-password';
raw.smtp.password = 'test-only-smtp-password';
raw.providers['caldav-service'].password = 'test-only-caldav-password';
expect(JSON.stringify(raw)).not.toContain('${');
const loaded = config.validateConfig(raw);
expect(loaded.auth.secret).toBeUndefined();
expect(loaded.url.app).toBe('https://when.example.com');
});
test('documents every schema field (as a key or a comment)', () => {
+23 -18
View File
@@ -1,20 +1,25 @@
# Copy to .env beside compose.yaml. Keep it private and out of Git.
# Generate AUTH_SECRET with: openssl rand -base64 32
AUTH_SECRET=
# When's public URL default for auth and links, not an adapter request-origin override.
ORIGIN=https://when.example.com
# Native adapter setting: the trusted proxy must overwrite X-Forwarded-Proto.
# Preserve the public Host and keep the backend private; see docs/deployment.md.
PROTOCOL_HEADER=x-forwarded-proto
# Optional: copy to .env beside compose.yaml only if you want environment overrides
# or ${VAR} references in when.yaml. The normal setup edits when.yaml directly.
# Keep this file private and out of Git. Uncomment only the values you use.
# Match the ${...} references in config/when.yaml.
WHEN_ADMIN_PASSWORD=
WHEN_SMTP_PASSWORD=
WHEN_PROVIDER_CALDAV_SERVICE_PASSWORD=
# Published host port only; container ports and internal service URLs stay unchanged.
# WHEN_HTTP_PORT=8080
# Optional services. Replace <NAME> with the provider key, uppercase, hyphens as _.
# WHEN_OIDC_CLIENT_SECRET=
# WHEN_PROVIDER_<NAME>_PASSWORD=
# WHEN_PROVIDER_<NAME>_CLIENT_SECRET=
# WHEN_PROVIDER_<NAME>_REFRESH_TOKEN=
# WHEN_METRICS_TOKEN=
# Example: use password: '${WHEN_ADMIN_PASSWORD}' in when.yaml.
# WHEN_ADMIN_PASSWORD=your-own-password
# WHEN_SMTP_PASSWORD=your-smtp-password
# WHEN_OIDC_CLIENT_SECRET=your-provider-client-secret
# WHEN_PROVIDER_CALDAV_SERVICE_PASSWORD=your-caldav-password
# WHEN_METRICS_TOKEN=your-scrape-token
# Optional stable session key; YAML auth.secret takes precedence.
# Generate once with: openssl rand -base64 32
# AUTH_SECRET=your-generated-secret-of-at-least-32-characters
# Optional defaults when omitted from YAML; explicit YAML values take precedence.
# ORIGIN=https://when.example.com
# TZ=UTC
# Set native PORT overrides per service in Compose, not in this shared file:
# web defaults to 3000, worker to 9000. Coordinate healthchecks and internal URLs
# if you change container ports. The web proxy settings live in compose.yaml.
+14 -4
View File
@@ -3,10 +3,16 @@ services:
# Pin web, worker and CLI to matching published versions for controlled updates.
image: ghcr.io/eleith/when:latest
platform: linux/amd64
env_file: .env
# Optional values for ${VAR} references in when.yaml.
env_file:
- path: .env
required: false
environment:
# Only trust this header from the private, configured reverse proxy.
PROTOCOL_HEADER: x-forwarded-proto
ports:
# Localhost only. Use '3000:3000' for LAN access; restrict it with your firewall.
- '127.0.0.1:3000:3000'
- '127.0.0.1:${WHEN_HTTP_PORT:-3000}:3000'
volumes:
- ./config:/app/config:ro
- ./data:/app/data
@@ -29,7 +35,9 @@ services:
when-worker:
image: ghcr.io/eleith/when-worker:latest
platform: linux/amd64
env_file: .env
env_file:
- path: .env
required: false
# Health and metrics stay on the Compose network. No published worker port.
volumes:
- ./config:/app/config:ro
@@ -51,7 +59,9 @@ services:
platform: linux/amd64
profiles:
- tools
env_file: .env
env_file:
- path: .env
required: false
volumes:
# Writable so config init can create a starter file. Other services only read it.
- ./config:/app/config
+36 -23
View File
@@ -56,8 +56,10 @@ form fields are checked at load time.
## Environment variables
Any string value can contain `${ENV_VAR}`. Values are substituted before runtime
validation, including in defaults. Keep secrets in the environment, not in
committed YAML.
validation, including in defaults. This is optional: literal values in private
YAML work without an environment file. If you prefer interpolation, the sample
Compose file passes an optional `.env` to web, worker and CLI. Keep files containing
secrets private and out of Git.
- `${VAR}` fails loading if the variable is unset.
- `${VAR:-fallback}` uses the fallback if the variable is unset, not if it is empty.
@@ -75,7 +77,7 @@ Choose **exactly one** strategy for `/admin`.
auth:
credentials:
username: admin
# password defaults to ${WHEN_ADMIN_PASSWORD}
password: '' # required: set your own password before starting
```
Or replace it with OIDC:
@@ -85,24 +87,34 @@ auth:
oidc:
issuer: 'https://auth.example.com'
client_id: when
client_secret: '${WHEN_OIDC_CLIENT_SECRET}'
client_secret: '' # required: your provider's client secret
# name: 'Single Sign-On' # default display name
```
`credentials.username` is required; `password` must be non-empty and defaults to
`${WHEN_ADMIN_PASSWORD}`. OIDC requires `issuer`, `client_id`, and `client_secret`;
`name` defaults to `Single Sign-On`.
`credentials.username` is required; `password` must be non-empty. Set it directly
in YAML or use interpolation. Omitting it still uses `${WHEN_ADMIN_PASSWORD}` for
existing environment-driven configs. OIDC requires `issuer`, `client_id`, and
`client_secret`; `name` defaults to `Single Sign-On`.
Optional `auth.secret` is a session signing key of at least 32 characters after
interpolation. With no YAML secret, web uses `AUTH_SECRET` if set; otherwise it
generates a secure random key at startup. Generated keys sign everyone out on web
restart. Set a stable YAML secret (or keep `AUTH_SECRET`) to preserve sessions.
Generate one once with `openssl rand -base64 32`. Ordinary config reloads keep the
current key. Empty or short configured keys are errors, not requests to generate
one. Structural CLI validation leaves interpolation references unresolved;
`config validate --check-env` checks the resolved YAML key's length.
Both strategies work in production. Choose based on who can reach sign-in:
- **Credentials:** suitable on a LAN, VPN, or behind an authenticating proxy.
The app has **no password-attempt rate limiting or lockout** and warns about
this at startup in production.
Native sign-in throttling uses the trusted `X-Real-IP` header. Keep the backend
private and have the proxy overwrite that header; this is not durable lockout.
- **OIDC:** recommended for an internet-facing deployment. Throttling, lockout,
password policy, and MFA belong to your identity provider.
If you expose credentials sign-in publicly, enforce rate limiting at the reverse
proxy.
If you expose credentials sign-in publicly, also enforce rate limiting at the
reverse proxy.
## `user`
@@ -150,13 +162,13 @@ and text.
Required. The worker uses this server for appointment emails.
| Field | Default | Meaning |
| ---------- | -------------------------- | --------------------------------------------- |
| `host` | Required | SMTP hostname. |
| `port` | `587` | Integer from 1–65535. |
| `username` | Required | SMTP username. |
| `password` | Required | SMTP password; use an environment reference. |
| `from` | `noreply@<url.app domain>` | Sender address your server is allowed to use. |
| Field | Default | Meaning |
| ---------- | -------------------------- | ------------------------------------------------- |
| `host` | Required | SMTP hostname. |
| `port` | `587` | Integer from 1–65535. |
| `username` | Required | SMTP username. |
| `password` | Required | SMTP password; literal or optional interpolation. |
| `from` | `noreply@<url.app domain>` | Sender address your server is allowed to use. |
`from` is also the organizer on guest-facing calendar invites, keeping the host's
own address out of those fields. The sender display name is always `user.name`.
@@ -193,8 +205,9 @@ An empty or omitted `refresh_token` means not connected. The config can load,
but the provider cannot reach Google until you connect it.
Connect from `/admin`. Google issues the token through browser consent; the admin
UI shows it once for you to save in an environment variable and reference in YAML.
After saving it, [recreate the containers](deployment.md#environment-variables).
UI shows it once. Save it as the provider's YAML `refresh_token`, or use optional
environment interpolation. Environment changes require
[recreating the containers](deployment.md#environment-variables).
## `schedules`
@@ -254,7 +267,6 @@ meetings:
| `form_fields` | Unset | Custom booking form below. |
| `start_times_every_minutes` | `30` | Positive integer slot step, independent of offered durations. |
| `notice_minutes` | `120` | Minimum lead time; integer ≥ 0. |
| `booking_window_days` | `60` | Days into the future open for booking; integer ≥ 1. |
| `padding_before_minutes` | `0` | Padding before appointments; integer ≥ 0. |
| `padding_after_minutes` | `0` | Padding after appointments; integer ≥ 0. |
| `daily_booking_limit` | `null` | Positive integer daily cap; `null` means unlimited. |
@@ -374,8 +386,9 @@ Treat both databases and their backups as sensitive; see
| `internal` | `${WHEN_URL_INTERNAL:-}` | Worker → web, including branding images embedded in email. Empty falls back to `app`. |
| `worker` | `http://when-worker:9000` | Web → worker telemetry and metrics. |
Usually, set `ORIGIN` and leave `url.app` alone. It supplies When's public URL
for authentication and generated links; an explicit `url.app` takes precedence.
Set `url.app` in YAML to your public HTTPS URL for authentication and generated
links. `ORIGIN` remains an optional default when that YAML field is omitted;
an explicit YAML value takes precedence.
SvelteKit 3's native adapter ignores `ORIGIN` and derives its request origin from
trusted proxy headers and Host instead. These must match the configured public
URL; see the [proxy requirements](deployment.md#reverse-proxy).
+38 -65
View File
@@ -1,39 +1,30 @@
# Deployment
Use Docker Compose with the [sample](compose.yaml). There are other ways; you're
welcome to them.
Use Docker Compose 2.24 or newer with the [sample](compose.yaml).
Start with the [quick start](../README.md#get-started) and
[example config](when.example.yaml).
## start up
Make `data/` writable by container UID 1000, or SQLite won't start.
Fill in `config/when.yaml`; no `.env` file is required. Keep the config private
and readable by container UID 1000; make `data/` writable by that UID.
```sh
docker compose up -d
```
Web listens on backend port 3000. Open the public proxy URL, not the backend.
Open your public proxy URL and `/admin`. Backend web port: 3000.
## reverse proxy
Production browser access requires a trusted reverse proxy. Set
`PROTOCOL_HEADER=x-forwarded-proto`; the proxy must overwrite `X-Forwarded-Proto`
with the public scheme and preserve the public `Host`, including any non-default
port. Reject inappropriate public hosts and keep the backend loopback-only or
private so clients cannot bypass the proxy and supply trusted headers themselves.
The adapter trusts these headers; it does not authenticate the proxy.
Set YAML `url.app` to your public HTTPS origin. The sample configures web to
trust `X-Forwarded-Proto`; the proxy must overwrite it and `X-Real-IP`, preserve
the public `Host` (including its port), and reject unexpected hosts. Keep the
backend loopback-only or private so clients cannot forge these headers.
If your proxy forwards the public authority in `X-Forwarded-Host` instead, set the
native `HOST_HEADER=x-forwarded-host` only with a matching proxy overwrite policy.
Do not set `PORT_HEADER` when the authority already includes the port.
`ORIGIN` supplies When's `url.app` default for authentication and generated links;
SvelteKit 3's native adapter ignores it. The configured public URL and the request
origin derived from proxy headers must agree. An explicit YAML `url.app` overrides
that default, not the adapter's request origin. Internal health and branding GETs
can still use backend HTTP.
If using `X-Forwarded-Host`, configure native `HOST_HEADER` and have the proxy
overwrite it too. Omit `PORT_HEADER` when Host already includes the port.
`ORIGIN` does not override SvelteKit 3's request origin.
## inspect logs
@@ -41,46 +32,42 @@ can still use backend HTTP.
docker compose logs --tail=100 -f
```
Visit `/healthz` for liveness and `/admin/health` for recorded service outcomes.
`/healthz` reports liveness; `/admin/health` shows recorded service outcomes.
## environment variables
Copy the [environment example](.env.example) to `.env` beside `compose.yaml` and
supply real values. After editing `.env`, run `docker compose up -d --force-recreate`.
A restart keeps the old environment.
Optional `.env` values are available to YAML `${VAR}` references in web, worker
and CLI. Use `.env` or a service's Compose `environment` mapping; arbitrary shell
variables are not automatically passed into containers. Keep secrets private.
See the [environment example](.env.example) and
[config reference](config.md#environment-variables).
| Variable | Used for |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `AUTH_SECRET` | Required Better Auth secret. Generate 32 random bytes with `openssl rand -base64 32`. |
| `ORIGIN` | Public base URL, no trailing slash. Default for When's `url.app`, used for auth and links; not an adapter override. |
| `PROTOCOL_HEADER` | Set to `x-forwarded-proto` behind the trusted production proxy, which must overwrite it with the public scheme. |
Change only the published web port:
Other secret names come from `${ENV_VAR}` references in your config. The example
uses:
```sh
WHEN_HTTP_PORT=8080 docker compose up -d
```
| Variable | Needed for |
| ------------------------------------ | ---------------------------------------------------------------- |
| `WHEN_ADMIN_PASSWORD` | Credentials sign-in. |
| `WHEN_OIDC_CLIENT_SECRET` | OIDC sign-in. |
| `WHEN_SMTP_PASSWORD` | SMTP. |
| `WHEN_PROVIDER_<NAME>_PASSWORD` | CalDAV or Nextcloud. |
| `WHEN_PROVIDER_<NAME>_CLIENT_SECRET` | Google. |
| `WHEN_PROVIDER_<NAME>_REFRESH_TOKEN` | Connected Google provider; obtained through admin, not invented. |
Other advanced overrides:
Advanced overrides:
| Variable | Purpose |
| ------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `CONFIG_PATH` | Config location; production default `/app/config/when.yaml`. |
| `WHEN_DATABASE_PATH` / `WHEN_QUEUE_DB_PATH` | Override YAML database paths. |
| `WHEN_PUBLIC_DIR` | Override the deployment's `public/` directory. |
| `PORT` | Per-service listen port: web 3000, worker 9000. Keep healthchecks and internal URLs aligned. |
| `WHEN_URL_INTERNAL` | Default for YAML `url.internal`. |
| Variable | Default / purpose |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `CONFIG_PATH` | `/app/config/when.yaml` in production; normally `<cwd>/config/when.yaml` otherwise. |
| `WHEN_DATABASE_PATH` / `WHEN_QUEUE_DB_PATH` | Override `database.app` / `database.queue`. |
| `WHEN_PUBLIC_DIR` | Override `<deployment root>/public`. |
| `PORT` | Web: 3000 in the production image. Worker: 9000. Don't give both the same override by accident. |
| `WHEN_URL_INTERNAL` | Default for `url.internal`, the worker-to-web address. Docker targets supply it. |
Keep runtime `PORT` overrides out of the shared `.env`. After changing container
environment values, run `docker compose up -d --force-recreate`.
Without a configured session key, web restarts sign everyone out. Set YAML
[`auth.secret`](config.md#auth) or retain `AUTH_SECRET` for persistent sessions.
## persistence
Back up both SQLite databases with SQLite-aware tooling; live file copies can miss
WAL data. Keep config, `.env` and branding overrides too. These contain private data.
Back up both SQLite databases with SQLite-aware tooling; live file copies can
miss WAL data. Keep private config, optional `.env` and branding overrides too.
## updates
@@ -90,23 +77,9 @@ Don't replay unfinished jobs through changed workflow graphs.
## Helper CLI
`when-cli` validates config and asks the worker to test services. Some commands
write config or databases, refresh busy times, or send real mail. Not a dry run.
Using the sample's environment and shared data mounts:
```sh
docker compose run --rm when-cli config validate --check-env
```
| Command | Effect |
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
| `config init` | Write starter YAML; refuse to overwrite. |
| `config validate [path]` | Check shape and cross-references; `--check-env` checks environment values too. Nonzero on failure. |
| `provider list` | Read recorded provider status; no network call. |
| `provider test <name>` | Authenticate through the running worker and record the outcome. |
| `provider calendars <name>` | Discover calendar `id` / `href` values through the worker. |
| `calendar list` | List configured calendars. |
| `calendar test <name>` | Refresh that calendar's busy mirror and record status. |
| `db status` / `db migrate` | Report schema state / apply pending app migrations. |
| `email test you@example.com` | Send a real branded test email through the worker. |
Use `docker compose run --rm when-cli --help` for config, provider, calendar,
database and email commands. Some write config/data or send real mail.
+8 -15
View File
@@ -9,33 +9,26 @@ docker. one is documented-ish. the other is not.
### docker development
With Docker, mise and fnox installed, start from `apps/web`:
With Docker installed, start from `apps/web`:
```sh
mkdir -p config data public
cp -n ../../docs/when.example.yaml config/when.yaml
cp -n ../../docs/.env.example .env
openssl rand -base64 32
chmod 600 config/when.yaml
```
Fill in config and secrets, put the generated value in `AUTH_SECRET`, and set
`ORIGIN=http://localhost:5173`. Create `docker-compose.dev.local.yml`:
```yaml
services:
when-app:
env_file: .env
when-worker:
env_file: .env
```
Fill in `config/when.yaml` and set YAML `url.app` to `http://localhost:5173`.
No `.env` file or session key is required. If you want interpolation, create an
optional `.env` beside the development Compose file. To keep sessions across
restarts, set `auth.secret` in YAML or supply a stable `AUTH_SECRET`.
Then run:
```sh
mise docker up --build
docker compose -f docker-compose.dev.yml up --build
```
The task combines this override with `docker-compose.dev.yml`. Web is on 5173,
Web is on 5173,
Mailpit on 8025. The [sample Compose file](compose.yaml) is for deployment, not
hot reload.
+22 -20
View File
@@ -1,24 +1,35 @@
# yaml-language-server: $schema=http://localhost:3000/schema.json
# Copy to config/when.yaml and replace the placeholders.
# The schema comes from the running app; adjust its URL if you use another address.
# Optional fields and defaults are commented below. Secrets use ${ENV_VAR};
# the sample Compose file passes them from .env.
# Edit this file with your real URL, account details and passwords before starting.
# No .env file is required. Keep this file private when it contains secrets.
# Optional fields and defaults are commented below. Any string can use ${ENV_VAR}
# from an optional .env file beside compose.yaml instead of a literal value.
# Shape of this file. Defaults to 1 and only changes if the config layout does.
# version: 1
# --- Your public URL: must match the trusted reverse proxy's public origin ---
url:
app: 'https://when.example.com'
# internal: 'http://when-app:3000' # worker → web; supplied by the worker image
# worker: 'http://when-worker:9000' # web → worker; default private Compose address
# --- Admin sign-in: exactly one of `credentials` or `oidc` ---
auth:
credentials:
username: admin
# password defaults to the ${WHEN_ADMIN_PASSWORD} env var
password: '' # required: set your own password before starting
# Or single sign-on instead of credentials:
# oidc:
# issuer: 'https://accounts.example.com'
# client_id: 'when'
# client_secret: '${WHEN_OIDC_CLIENT_SECRET}'
# client_secret: '' # required: your provider's client secret
# # name defaults to 'Single Sign-On'
# # name: 'Single Sign-On'
# Omit secret to generate a secure session key at startup; restarts sign you out.
# To keep sessions across restarts, generate a value once: openssl rand -base64 32
# secret: '${AUTH_SECRET}' # or put the generated value directly here
# --- The schedule owner ---
user:
@@ -47,7 +58,7 @@ user:
smtp:
host: 'smtp.example.com'
username: 'you@example.com'
password: '${WHEN_SMTP_PASSWORD}'
password: '' # required: your SMTP password
# port defaults to 587
# port: 465
# from defaults to noreply@<your url.app domain>
@@ -62,7 +73,7 @@ providers:
type: 'caldav'
url: 'https://caldav.example.com/dav/' # DAV base URL
username: 'you'
password: '${WHEN_PROVIDER_CALDAV_SERVICE_PASSWORD}'
password: '' # required: your CalDAV password
calendars:
main:
href: 'calendars/you/main/' # joined to the provider url, or give a full URL instead
@@ -72,10 +83,10 @@ providers:
# google-service:
# type: 'google'
# client_id: '...apps.googleusercontent.com'
# client_secret: '${WHEN_PROVIDER_GOOGLE_SERVICE_CLIENT_SECRET}'
# # Optional — omit it and the provider simply reads as not connected. Keeping the
# # line with its `:-` fallback means connecting later is one edit to .env, not two.
# refresh_token: '${WHEN_PROVIDER_GOOGLE_SERVICE_REFRESH_TOKEN:-}'
# client_secret: '' # required: your Google client secret
# # Optional — omit it and the provider reads as not connected. Connect from
# # /admin, then save the supplied token here (or use optional interpolation).
# refresh_token: ''
# calendars:
# personal:
# id: 'primary'
@@ -84,7 +95,7 @@ providers:
# type: 'nextcloud'
# url: 'https://cloud.example.com'
# username: 'you'
# password: '${WHEN_PROVIDER_NEXTCLOUD_SERVICE_PASSWORD}'
# password: '' # required: your Nextcloud password
# calendars:
# shared:
# href: 'calendars/you/shared/'
@@ -164,15 +175,6 @@ meetings:
# app: './data/when.sqlite'
# queue: './data/openworkflow.sqlite'
# --- Public and internal URLs ---
# url:
# app defaults to ${ORIGIN:-http://localhost:5173} for auth and generated links.
# Set ORIGIN in .env, or override app here. Neither sets the native adapter's
# request origin: matching trusted proxy headers and Host do (see deployment.md).
# app: 'https://when.example.com' # public base URL, used in emails and calendar events
# internal: 'http://when-app:3000' # worker → web (defaults to ${WHEN_URL_INTERNAL})
# worker: 'http://when-worker:9000' # web → worker
# --- Prometheus metrics (off by default) ---
# prometheus:
# enabled: false
+12 -2
View File
@@ -173,6 +173,11 @@
"credentials": {
"description": "Disabled when OIDC is active.",
"type": "null"
},
"secret": {
"minLength": 1,
"description": "Optional session signing secret: at least 32 characters after environment interpolation. Defaults to AUTH_SECRET when set, otherwise a random secret generated at auth startup. Set this to keep sessions across restarts.",
"type": "string"
}
}
},
@@ -189,6 +194,11 @@
"oidc": {
"description": "Disabled when Credentials auth is active.",
"type": "null"
},
"secret": {
"minLength": 1,
"description": "Optional session signing secret: at least 32 characters after environment interpolation. Defaults to AUTH_SECRET when set, otherwise a random secret generated at auth startup. Set this to keep sessions across restarts.",
"type": "string"
}
}
}
@@ -309,7 +319,7 @@
"password": {
"minLength": 1,
"default": "${WHEN_ADMIN_PASSWORD}",
"description": "Admin password. Defaults to the WHEN_ADMIN_PASSWORD environment variable.",
"description": "Admin password: a YAML literal or an optional environment reference. If omitted, uses the WHEN_ADMIN_PASSWORD environment variable.",
"type": "string"
}
}
@@ -560,7 +570,7 @@
},
"refresh_token": {
"default": "",
"description": "Google OAuth refresh token. Connect the provider from /admin, which shows the token for you to store in an env var and reference here. Empty means not connected.",
"description": "Google OAuth refresh token. Connect the provider from /admin, then save the supplied token here or use optional environment interpolation. Empty means not connected.",
"type": "string"
},
"calendars": {
+87 -1
View File
@@ -1,5 +1,8 @@
import { expect, test } from 'vitest';
import { ConfigError, validateConfig, validateStructure } from './load.js';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ConfigError, loadConfigFile, validateConfig, validateStructure } from './load.js';
import { MissingEnvVarsError } from './interpolate.js';
import { validConfig } from './__fixtures__/valid-config.js';
import type { WhenConfiguration } from './schema.js';
@@ -8,6 +11,27 @@ function clone<T>(v: T): T {
return JSON.parse(JSON.stringify(v));
}
test('malformed YAML reports its position without echoing an inline password', async () => {
const directory = await mkdtemp(join(tmpdir(), 'when-config-redaction-'));
const path = join(directory, 'when.yaml');
const secret = 'SYNTHETIC_PRIVATE_PASSWORD_SENTINEL';
try {
await writeFile(path, `auth:\n credentials:\n password: "${secret}\n`);
let failure: unknown;
try {
await loadConfigFile(path);
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(ConfigError);
expect(String(failure)).toMatch(/invalid YAML syntax at line \d+, column \d+ \(MISSING_CHAR\)/);
expect(String(failure)).not.toContain(secret);
expect(JSON.stringify((failure as ConfigError).issues)).not.toContain(secret);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test('valid config passes schema validation', () => {
const cfg = validateConfig(clone(validConfig));
expect(cfg.user.name).toBe('Jane Doe');
@@ -102,6 +126,68 @@ test('oidc auth preserves custom name override', () => {
expect(loaded.auth.oidc?.name).toBe('Authelia');
});
test.for<WhenConfiguration['auth']>([
validConfig.auth,
{
oidc: {
issuer: 'https://auth.example.com',
client_id: 'when',
client_secret: 'test',
name: 'SSO'
}
}
])('either auth strategy accepts an optional persistent session secret', (auth) => {
const raw = { ...clone(validConfig), auth };
expect(validateConfig(raw).auth.secret).toBeUndefined();
const secret = 'a'.repeat(32);
expect(validateConfig({ ...raw, auth: { ...auth, secret } }).auth.secret).toBe(secret);
});
test('structural session-secret references stay unresolved; runtime checks the resolved length', () => {
const raw = { ...clone(validConfig), auth: { ...validConfig.auth, secret: '${AUTH_SECRET}' } };
const previous = process.env.AUTH_SECRET;
delete process.env.AUTH_SECRET;
try {
expect(validateStructure(raw).auth.secret).toBe('${AUTH_SECRET}');
expect(() => validateConfig(raw)).toThrow(MissingEnvVarsError);
process.env.AUTH_SECRET = 'a'.repeat(32);
expect(validateConfig(raw).auth.secret).toBe(process.env.AUTH_SECRET);
for (const value of ['', 'a'.repeat(31)]) {
process.env.AUTH_SECRET = value;
expect(() => validateConfig(raw)).toThrow(ConfigError);
}
} finally {
if (previous === undefined) delete process.env.AUTH_SECRET;
else process.env.AUTH_SECRET = previous;
}
});
test('short literal session-secret errors identify the field without exposing its value', () => {
const secret = 'private-short-session-key';
const raw = { ...clone(validConfig), auth: { ...validConfig.auth, secret } };
expect(validateStructure(raw).auth.secret).toBe(secret);
try {
validateConfig(raw);
throw new Error('expected ConfigError');
} catch (error) {
expect(error).toBeInstanceOf(ConfigError);
expect((error as ConfigError).issues).toEqual([
{
path: '/auth/secret',
message: 'must contain at least 32 characters after environment interpolation'
}
]);
expect(String(error)).not.toContain(secret);
expect(JSON.stringify((error as ConfigError).issues)).not.toContain(secret);
}
});
test.for(['', null, 32])('an empty or non-string session secret is rejected: %s', (secret) => {
const raw = { ...clone(validConfig), auth: { ...validConfig.auth, secret } };
expect(() => validateStructure(raw)).toThrow(ConfigError);
expect(() => validateConfig(raw)).toThrow(ConfigError);
});
test('invalid email format fails', () => {
const bad = clone(validConfig);
bad.user.email = 'not-an-email';
+19 -6
View File
@@ -1,6 +1,6 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { parse as parseYaml } from 'yaml';
import { LineCounter, YAMLParseError, parse as parseYaml } from 'yaml';
import { FormatRegistry, type TSchema } from '@sinclair/typebox';
import { Value, type ValueError } from '@sinclair/typebox/value';
import * as schemas from './schema.js';
@@ -51,7 +51,16 @@ export function validateConfig(raw: unknown): WhenConfiguration {
const interpolated = interpolate(withDefaults);
const normalized = withDerivedDefaults(interpolated) as WhenConfiguration;
return checkAgainstSchema(normalized);
const config = checkAgainstSchema(normalized);
if (config.auth.secret !== undefined && config.auth.secret.length < 32) {
throw new ConfigError('config failed auth secret validation', [
{
path: '/auth/secret',
message: 'must contain at least 32 characters after environment interpolation'
}
]);
}
return config;
}
// Validate shape and cross-references without interpolating `${VAR}` refs, so a
@@ -80,12 +89,16 @@ function checkAgainstSchema(normalized: WhenConfiguration): WhenConfiguration {
async function readAndParse(path: string): Promise<unknown> {
const source = await readFile(path, 'utf8');
const lineCounter = new LineCounter();
try {
return parseYaml(source);
return parseYaml(source, { lineCounter, prettyErrors: false, logLevel: 'error' });
} catch (err) {
throw new ConfigError(`${path} is not valid YAML`, [
{ path: '/', message: err instanceof Error ? err.message : String(err) }
]);
let message = 'invalid YAML syntax';
if (err instanceof YAMLParseError) {
const { line, col } = lineCounter.linePos(err.pos[0]);
message += ` at line ${line}, column ${col} (${err.code})`;
}
throw new ConfigError(`${path} is not valid YAML`, [{ path: '/', message }]);
}
}
@@ -31,6 +31,13 @@ describe('public editor schema asset', () => {
expect(generatedSchema.required).not.toContain('providers');
expect(generatedSchema.$defs.Meeting.required).toContain('title');
expect(generatedSchema.$defs.Meeting.required).not.toContain('duration_minutes');
for (const auth of generatedSchema.$defs.Auth.anyOf) {
expect(auth.required).not.toContain('secret');
expect(auth.properties.secret.minLength).toBe(1);
expect(auth.properties.secret.description).toContain(
'32 characters after environment interpolation'
);
}
const refs = references(generatedSchema);
expect(refs.length).toBeGreaterThan(0);
for (const reference of refs) expect(reference).toMatch(/^#/);
+12 -4
View File
@@ -33,18 +33,26 @@ export const CredentialsAuthSchema = Type.Object({
password: Type.String({
minLength: 1,
default: '${WHEN_ADMIN_PASSWORD}',
description: 'Admin password. Defaults to the WHEN_ADMIN_PASSWORD environment variable.'
description: 'Admin password: a YAML literal or an optional environment reference. If omitted, uses the WHEN_ADMIN_PASSWORD environment variable.'
})
}, { $id: 'CredentialsAuth', additionalProperties: false, title: 'CredentialsAuth', description: 'Local username/password authentication.' });
// Validate the resolved length at runtime; structural checks must accept ${VAR} references.
const authSecret = Type.Optional(Type.String({
minLength: 1,
description: 'Optional session signing secret: at least 32 characters after environment interpolation. Defaults to AUTH_SECRET when set, otherwise a random secret generated at auth startup. Set this to keep sessions across restarts.'
}));
export const AuthSchema = Type.Union([
Type.Object({
oidc: Ref(OidcAuthSchema, { description: 'OIDC authentication provider configuration.' }),
credentials: Type.Optional(Type.Null({ description: 'Disabled when OIDC is active.' }))
credentials: Type.Optional(Type.Null({ description: 'Disabled when OIDC is active.' })),
secret: authSecret
}, { additionalProperties: false, title: 'Single sign-on (oidc)' }),
Type.Object({
credentials: Ref(CredentialsAuthSchema, { description: 'Credentials authentication configuration.' }),
oidc: Type.Optional(Type.Null({ description: 'Disabled when Credentials auth is active.' }))
oidc: Type.Optional(Type.Null({ description: 'Disabled when Credentials auth is active.' })),
secret: authSecret
}, { additionalProperties: false, title: 'Username and password (credentials)' })
], {
$id: 'Auth',
@@ -114,7 +122,7 @@ export const GoogleProviderSchema = Type.Object({
type: Type.Literal('google', { description: 'Provider type: must be google.' }),
client_id: Type.String({ minLength: 1, description: 'Google OAuth client ID.' }),
client_secret: Type.String({ minLength: 1, description: 'Google OAuth client secret.' }),
refresh_token: Type.String({ default: '', description: 'Google OAuth refresh token. Connect the provider from /admin, which shows the token for you to store in an env var and reference here. Empty means not connected.' }),
refresh_token: Type.String({ default: '', description: 'Google OAuth refresh token. Connect the provider from /admin, then save the supplied token here or use optional environment interpolation. Empty means not connected.' }),
calendars: Type.Record(Type.String({ pattern: '^[a-z0-9][a-z0-9-]*$' }), Ref(GoogleCalendarSchema, { default: {} }), { additionalProperties: false, default: {}, description: 'Calendars served by this provider, keyed by the name meetings reference.' })
}, { $id: 'GoogleProvider', additionalProperties: false, title: 'GoogleProvider', description: 'Google API provider credentials.' });