feat(config): make Docker onboarding YAML-first #114
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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,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) =>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(/^#/);
|
||||
|
||||
@@ -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.' });
|
||||
|
||||
|
||||
Reference in new issue
Block a user