diff --git a/.changeset/yaml-first-onboarding.md b/.changeset/yaml-first-onboarding.md new file mode 100644 index 00000000..1d5add37 --- /dev/null +++ b/.changeset/yaml-first-onboarding.md @@ -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. diff --git a/README.md b/README.md index 80f4f279..78931a14 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/cli/src/commands/config/init.test.ts b/apps/cli/src/commands/config/init.test.ts index c25ade69..09746ea5 100644 --- a/apps/cli/src/commands/config/init.test.ts +++ b/apps/cli/src/commands/config/init.test.ts @@ -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>[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')); }); diff --git a/apps/cli/src/commands/config/init.ts b/apps/cli/src/commands/config/init.ts index 1fff514b..ddf2cca1 100644 --- a/apps/cli/src/commands/config/init.ts +++ b/apps/cli/src/commands/config/init.ts @@ -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' ); diff --git a/apps/cli/src/commands/config/validate.test.ts b/apps/cli/src/commands/config/validate.test.ts index c2be6e4b..7725f726 100644 --- a/apps/cli/src/commands/config/validate.test.ts +++ b/apps/cli/src/commands/config/validate.test.ts @@ -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>[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 = { diff --git a/apps/web/docker-compose.dev.yml b/apps/web/docker-compose.dev.yml index 4a96885c..3d299dc3 100644 --- a/apps/web/docker-compose.dev.yml +++ b/apps/web/docker-compose.dev.yml @@ -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: diff --git a/apps/web/docker-compose.yml b/apps/web/docker-compose.yml index 6384cc4e..e178de95 100644 --- a/apps/web/docker-compose.yml +++ b/apps/web/docker-compose.yml @@ -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 diff --git a/apps/web/src/lib/appointmentFlow.svelte.test.ts b/apps/web/src/lib/appointmentFlow.svelte.test.ts index 2d2f93e4..cf1dfc8c 100644 --- a/apps/web/src/lib/appointmentFlow.svelte.test.ts +++ b/apps/web/src/lib/appointmentFlow.svelte.test.ts @@ -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); diff --git a/apps/web/src/lib/appointmentFlow.svelte.ts b/apps/web/src/lib/appointmentFlow.svelte.ts index abe40e97..e6311d81 100644 --- a/apps/web/src/lib/appointmentFlow.svelte.ts +++ b/apps/web/src/lib/appointmentFlow.svelte.ts @@ -19,6 +19,7 @@ export interface AppointmentFlow { readonly availableDates: Set; readonly offeredDates: Set; 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; }, diff --git a/apps/web/src/lib/components/AppointmentWizard.svelte b/apps/web/src/lib/components/AppointmentWizard.svelte index d3d5e6b8..1cd5da38 100644 --- a/apps/web/src/lib/components/AppointmentWizard.svelte +++ b/apps/web/src/lib/components/AppointmentWizard.svelte @@ -1,6 +1,5 @@