docs: simplify guides and publish deployment examples #99

Merged
eleith merged 1 commits from docs/directory-style into main 2026-10-27 02:10:09 +00:00
19 changed files with 662 additions and 1689 deletions

No files matched your search

+29 -38
View File
@@ -1,51 +1,42 @@
# When
# 📅 When
Single-user appointment scheduling. A public appointment page, an admin UI, calendar
integration (Google + CalDAV), and email notifications — all configured through one
YAML file.
Appointment scheduling for one person. You, probably.
"When" runs as two services that share a single `when.yaml` and data directory:
the **web** app (appointment page + admin) and a background **worker** that handles
calendar sync and sends appointment emails off the request path. The bundled
`apps/web/docker-compose.yml` runs both.
A public booking page, an admin UI, Google and CalDAV calendars, and email
notifications. Configure it in YAML. Keep your appointments in SQLite.
## Quick start (Docker)
No teams. No round-robin routing. No subscription to your own calendar.
1. Copy `apps/web/config/when.example.yml` to `apps/web/config/when.yaml` and fill it in. Point
your editor at the bundled schema for autocomplete and validation — the example's
first line already does this:
## Get started
```yaml
# yaml-language-server: $schema=./config.schema.json
```
You'll need Docker Compose. In a fresh directory:
See [`docs/config.md`](docs/config.md) for the full configuration reference.
```sh
mkdir when
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
```
2. Copy `apps/web/.env.example` to `apps/web/.env` and fill it in — the compose file
loads it into all three services. Generate the secret it asks for:
Edit `.env` and `config/when.yaml` to make it all cozy and comfortable.
Make `data/` writable by container UID 1000; see [permissions](docs/deployment.md#start-up).
```sh
openssl rand -base64 32 # -> AUTH_SECRET
```
```sh
docker compose up -d
```
Set `ORIGIN` to the public URL you serve from; behind a reverse proxy, form
submissions fail with a 403 without it. The full list of environment variables is in
[`docs/deployment.md`](docs/deployment.md).
Open http://localhost:3000 and `/admin` to get a lay of the land.
3. Bring up both services:
```sh
docker compose -f apps/web/docker-compose.yml up -d
```
The appointment page is at `http://localhost:3000/` and the admin UI at `/admin`. The
container runs database migrations on boot. SQLite lives under `/app/data`; the
bundled compose file mounts `./data` so appointments survive restarts.
There is a lot more going on under the hood, read more documentation if you
want to learn more.
## Documentation
- [Philosophy](docs/philosophy.md) — what "When" is, and the principles behind it.
- [Architecture](docs/architecture.md) — how the system is built.
- [Configuration](docs/config.md) — the full `when.yaml` reference.
- [Deployment](docs/deployment.md) — environment variables, Docker, running behind a reverse proxy, persistence, and operating endpoints.
- [Development](docs/development.md) — running it locally, the scripts, testing, and coding conventions.
- [Configuration](docs/config.md) — fields, defaults and examples.
- [Deployment](docs/deployment.md) — run it, update it, recover it.
- [Development](docs/development.md) — change it without making a hobby of it.
- [Philosophy](docs/philosophy.md) — what belongs here. And what doesn't.
- [Architecture](docs/architecture.md) — where the pieces live.
+7 -4
View File
@@ -4,9 +4,10 @@ import { define } from 'gunshi';
import { getValidatedConfigPath } from '../../utils/config-path.ts';
import { pass, fail } from '../../utils/report.ts';
const SKELETON = `# yaml-language-server: $schema=../../../packages/config/src/config.schema.json
# Minimal starter config. Edit the placeholders, set the \${...} env vars, then
# run: when-cli config validate. See config/when.example.yml for every option.
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.
# Full example: https://git.eleith.com/eleith/when/raw/branch/main/docs/when.example.yaml
auth:
credentials:
username: admin
@@ -70,6 +71,8 @@ export const initCommand = define({
pass(`wrote ${path}`);
console.log(' edit the placeholders, then run: when-cli config validate');
console.log(' see config/when.example.yml for every option');
console.log(
' see https://git.eleith.com/eleith/when/src/branch/main/docs/when.example.yaml for every option'
);
}
});
-28
View File
@@ -1,28 +0,0 @@
# Copy to `.env` alongside this file and fill in. Both compose files load it with
# `env_file:`, so nothing is baked into the image — ship the image, bring your own
# `.env` and `config/when.yaml`.
# --- Read directly by the app ---
# Auth.js JWT signing secret. Required, 32+ characters.
# openssl rand -base64 32
AUTH_SECRET= development-only-secret-please-change-it-32chars
# Public base URL, no trailing slash — e.g. https://when.example.com
# Required behind a reverse proxy: without it every form POST fails SvelteKit's
# CSRF origin check with a 403. Also the default for `url.app` in when.yaml, so
# set it here and leave that unset rather than writing the URL twice.
ORIGIN=
# --- Referenced from when.yaml via ${...} ---
# These names are whatever your config uses. Below are the ones `when-cli config
# init` and `config/when.example.yml` write; `<NAME>` is the provider's key
# upper-cased, with `-` as `_`.
WHEN_ADMIN_PASSWORD=
WHEN_SMTP_PASSWORD=
# WHEN_OIDC_CLIENT_SECRET=
# WHEN_PROVIDER_<NAME>_PASSWORD=
# WHEN_PROVIDER_<NAME>_CLIENT_SECRET=
# WHEN_PROVIDER_<NAME>_REFRESH_TOKEN=
# WHEN_METRICS_TOKEN=
+1 -1
View File
@@ -18,7 +18,7 @@ export async function bootConfig(path: string = resolveConfigPath()): Promise<Wh
if (!exists) {
logger.fatal(
{ path },
'config not found; copy config/when.example.yml to config/when.yaml as a starting point'
'config not found; start with the example at https://git.eleith.com/eleith/when/src/branch/main/docs/when.example.yaml'
);
throw new Error(`config not found at ${path}`);
}
@@ -4,7 +4,7 @@ import { join } from 'node:path';
import { parse as parseYaml } from 'yaml';
import * as config from '@when/config';
const examplePath = join(process.cwd(), 'config', 'when.example.yml');
const examplePath = join(process.cwd(), '..', '..', 'docs', 'when.example.yaml');
const exampleText = readFileSync(examplePath, 'utf8');
// Field names the example intentionally leaves undocumented (none today). Add a
@@ -36,7 +36,7 @@ function allSchemaFieldNames(): Set<string> {
return names;
}
describe('config/when.example.yml', () => {
describe('docs/when.example.yaml', () => {
test('is a structurally valid config', () => {
expect(() => config.validateStructure(parseYaml(exampleText))).not.toThrow();
});
+3 -174
View File
@@ -1,176 +1,5 @@
# @when/worker
The `when` worker is a long-running Node service for appointment side effects,
calendar maintenance, and on-demand service probes. Built on
[OpenWorkflow](https://openworkflow.dev) `0.11.0` over `node:sqlite`, it shares the
`when.sqlite` app DB and the `openworkflow.sqlite` queue with producers.
Web enqueues `reconcile-appointment` when an appointment changes. The worker
coordinates calendar/video updates, renders emails, sends them over SMTP, and
records outcomes in the appointment action log and service status. It also handles
purge, calendar-repair requests, video-link generation, and service probes. On boot
it loads config, migrates the app DB, connects the queue, registers workflows,
starts polling with one execution slot and a 30-second lease, and serves `/healthz`.
Native `worker.start()` persists declared schedules before startup enqueues all three
maintenance passes with `{}` and no run options.
## Run it
```sh
# from the repo root; build is a tsc compile to dist/ (run with node)
pnpm --filter @when/worker build && pnpm --filter @when/worker start
# or watch mode in dev, pointed at the shared config. The app + queue DB paths
# come from that config's `database` section, resolved relative to it — so this
# opens apps/web/data/{when.sqlite,openworkflow.sqlite}, same as web.
CONFIG_PATH=apps/web/config/when.yaml pnpm --filter @when/worker dev
```
`GET http://localhost:9000/healthz` → `{"status":"ok"}`.
## Configuration
Database locations live in `when.yaml` (the source of truth), not env vars:
```yaml
database:
app: ./data/when.sqlite # default
queue: ./data/openworkflow.sqlite # default
```
Relative paths resolve against the config directory's parent (the deployment root), so web and worker
(sharing one `when.yaml`) open the same files.
## Environment
- `CONFIG_PATH` — path to `when.yaml` (default `/app/config/when.yaml` in prod,
`<cwd>/config/when.yaml` otherwise).
- `WHEN_DATABASE_PATH` / `WHEN_QUEUE_DB_PATH` — optional escape hatches that override the
config's `database.app` / `database.queue`.
- `PORT` — health server port (default `9000`).
## Scripts
- `pnpm dev` — build once, then `tsc --watch` + `node --watch dist/src/index.js`
(source changes rebuild and restart).
- `pnpm build` — `tsc -p tsconfig.build.json` → `dist/`.
- `pnpm start` — `node dist/src/index.js`.
- `pnpm check` / `pnpm lint` / `pnpm test` / `pnpm test:coverage`.
## Appointment jobs
The worker-local `notify-appointment` workflow saves prepared email content and
recipient roles, then starts one `deliver-appointment-email` child per recipient. Each child has five native SMTP
attempts (30-second initial delay, doubling up to ten minutes). A successful send
is checkpointed separately from its status write. Host and guest keep independent
retry budgets, even when they share an email address.
Normal bookings, nonmatching attachment rules and already-selected links use the
notification workflow directly. A pending/confirmed booking that needs an automatic
link delegates its original notification to Generate instead of sending it separately.
Generate makes one provider attempt, publishes an immediately ready link, then sends
the original notice with or without that link. First-attempt success therefore needs
only the original notification; pending or failed generation can produce a later
"Video link added" update. Manual Generate remains update-only; removal uses the
existing edit notification, while canceling an unfulfilled request is silent.
Expected provider failures are returned as data for the first attempt, not retried
before the original notice. That initial video attempt has one five-second deadline,
shared by its HTTP calls, including Google token refresh. Calendar sync, later video
retries and SMTP do not inherit this deadline. The notification child
returns the selected link in its saved envelope snapshot. Generate continues only
if that original notice lacked a link. The original notice has its own native child
checkpoint, so video retries do not resend it. Its SMTP retries finish before video
continuation; exhausted notification delivery stops that continuation for operator
review. Physical inbox arrival order is still not guaranteed.
Generate checkpoints provider requests separately from local publication and
notification delivery. Accepted pending Google requests use read-only native retries
(ten attempts, 30-second initial delay, doubling to a ten-minute cap); they are not
resubmitted. Current choice, revision, selection and relevant event binding guard
publication. Every newly attached background link advances ICS and calendar revision;
manual intent logs the host edit, while automatic attachment logs a system edit.
The existing builders and role-specific SMTP children deliver both notices through
`notify-appointment`—Generate never sends SMTP directly. Saved intent is not job progress and workers never rewrite it.
Maintenance still repairs dirty calendar content and cleans unwanted resources; it
does not create rooms or restart exhausted generation. Broader late-ready observation
is not included here. Generation failure leaves the booking intact and requires
operator review; application-write/enqueue and effect-before-checkpoint gaps remain.
One worker slot means active provider calls can still delay other work, but retry
waits park; the original notice follows one bounded attempt rather than the full
generation retry budget.
Purge deletes each remote event with up to thirty native attempts (one-minute
initial delay, doubling up to one hour). Exhaustion fails the job and retains the
current and later hidden `purged` rows, including local personal data. Clicking
purge again is not promised recovery; cleanup requires explicit operator review.
Email action logs describe attempts, not terminal job outcomes. See
[interpreting outcomes](../../docs/deployment.md#interpreting-outcomes) for email
log/service-status meanings and purge recovery guidance.
Prepared envelopes include personal data and guest capability links: treat queue
backups like application backups. Effects are at-least-once: a crash after remote
acceptance but before saving success can still duplicate effects or action-log entries.
Metrics and logs are not exactly-once ledgers.
Deploy web, worker and CLI together only after draining old work. Follow the
[grouped rollout runbook](../../docs/deployment.md#grouped-workflow-rollout) for read-only
inspection, paired SQLite backups, restore verification and rollback restrictions.
Do not replay unfinished old workflow histories through changed step graphs.
## Engine integration
Engine polling, schedule, heartbeat, and execution diagnostics go through the
worker's Pino logger. The adapter emits operational categories without forwarding
arbitrary engine error text or payloads that may contain credentials or guest links.
The existing job metrics describe **handler executions**, including replays. An
escaped handler execution may mean durable suspension rather than a terminal job
failure. Email metrics count physical SMTP attempt outcomes, not an exactly-once
delivery ledger.
## Maintenance and shutdown
| Workflow | UTC cron | Stable schedule ID | Result |
| ----------------------------- | -------------- | ---------------------------------- | ----------- |
| `refresh-calendars` | `*/5 * * * *` | `when:refresh-calendars` | `refreshed` |
| `expire-pending-appointments` | `0 * * * *` | `when:expire-pending-appointments` | `expired` |
| `sync-calendars` | `*/10 * * * *` | `when:sync-calendars` | `requested` |
All inputs are `{}`. Each handler awaits the actual pass in durable steps; `requested`
means the scan finished, not that an in-memory request was accepted. Refresh checks
per-calendar due intervals, keeps stale mirrors on provider fetch failure, and runs
before conflict checks. Expiry SQL is idempotent and dirties newly expired pending rows
for repair; already-expired equal-revision rows are not backfilled. Subsequent callbacks
read live config.
Expected provider failures record status for later passes; local errors escape to native
retries. Completion is not a promise that every provider succeeded.
Mirror freshness is proven only by the latest successful calendar refresh or calendar
test, including an empty result. Pushes, failures and unknown/inconsistent observations
make a calendar due on the next pass, even when an older success timestamp remains.
Provider authentication/discovery does not refresh a mirror. The worker and web health
share this evidence check: untrusted refresh time is unknown, while explicit errors
remain failures. This conservative policy can fetch more often during push traffic or
sustained failure; it adds no timestamp column or freshness history.
Cron follows UTC wall-clock boundaries, not time since the last pass finished. After
downtime it dispatches one overdue run per schedule. Native startup upserts preserve
administrative pause state, but independent startup runs still execute while paused;
startup and overdue runs can coexist. See [schedule management](../../packages/jobs/README.md#schedule-management)
for the public list/pause/resume/delete API; removing declarations does not retire stored schedules.
No application maintenance timers or coalescers remain. Every cron/manual request gets
its own pass. One slot serializes active work: slow calls block appointment jobs and
probes, and repeated scans can build a backlog. Native retry delays park and release
the slot. Ordinary function steps use the engine default of ten attempts, one-second
initial delay, doubling to a 100-second cap. Workflow backstops are not extra provider
budgets; claim counts include resumes, and native exhaustion is terminal. Bookkeeping
failures can consume step attempts too: these limits are not exact physical-call counts.
Signals and config restarts share shutdown: stop config watching and HTTP admission,
await `worker.stop()`, finish HTTP closure, close the queue, then close the app DB.
Stop waits for active passes to finish or park, not for pending work/future retries;
it is not queue draining. Force-kill recovery relies on saved history and lease expiry.
Calendar sync, video links and email delivery, off the booking request path.
Run it alongside web with the same config and databases; see
[development](../../docs/development.md) and [deployment](../../docs/deployment.md).
+16
View File
@@ -0,0 +1,16 @@
# Copy to .env beside compose.yaml. Keep it private and out of Git.
# Generate AUTH_SECRET with: openssl rand -base64 32
AUTH_SECRET=
ORIGIN=http://localhost:3000
# Match the ${...} references in config/when.yaml.
WHEN_ADMIN_PASSWORD=
WHEN_SMTP_PASSWORD=
WHEN_PROVIDER_CALDAV_SERVICE_PASSWORD=
# 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=
+53 -248
View File
@@ -1,270 +1,75 @@
# Architecture
How "When" is built. For _why_ it's built this way, see
[`philosophy.md`](philosophy.md).
One booking app. Two processes. Shared files.
## Stack
This is the map. The code has the street names.
Node 26, TypeScript (`strict`), pnpm workspaces. SvelteKit (`@sveltejs/adapter-node`)
for the web app. SQLite through Node's built-in `node:sqlite` behind a small Kysely
dialect. [openworkflow](https://openworkflow.dev) for durable background jobs. Vitest
for tests, Prettier + ESLint for formatting/linting. Email templates render with Eta;
zoned time math uses Node's native `Temporal` on the server and `temporal-polyfill`'s
function-style entrypoints (`temporal-polyfill/fns/*`) in the browser; calendar iCal in
and out goes through `ts-ics`.
## The pieces
## Monorepo layout
Node 26, strict TypeScript and pnpm workspaces. SvelteKit serves web;
`node:sqlite` and Kysely handle data. OpenWorkflow runs background jobs.
A pnpm workspace (`apps/*`, `packages/*`). Tests are co-located as `*.test.ts` next to
the code they cover — there is no top-level `tests/` directory.
| Path | Job |
| --------------------- | --------------------------------------------- |
| `apps/web` | Booking, admin and HTTP routes. |
| `apps/worker` | Calendar I/O, video links and email. |
| `apps/cli` | Config validation and operator commands. |
| `packages/config` | TypeBox schema, YAML loading and validation. |
| `packages/db` | App database, queries and migrations. |
| `packages/jobs` | Workflow contracts and queue client. |
| `packages/calendar` | Google/CalDAV adapters and local ICS helpers. |
| `packages/video-chat` | Google Meet and Nextcloud Talk adapters. |
| Path | Role |
| --------------------- | -------------------------------------------------------------------------------------------- |
| `apps/web` | SvelteKit app: appointment page, admin UI, API routes. Also holds `e2e/` (Playwright). |
| `apps/worker` | Long-running background service: calendar sync + email delivery. See its README. |
| `apps/cli` | Operator CLI (`when-cli`): config validation, service/calendar testing, and email test. |
| `packages/config` | Canonical `when.yaml` schema, generated types, loader/validator. See its README. |
| `packages/db` | SQLite data layer: `node:sqlite` + Kysely dialect, schema types, migrations. See its README. |
| `packages/jobs` | The job/workflow contract shared by web (producer) and worker (consumer). See its README. |
| `packages/calendar` | External-calendar I/O: provider adapters, busy-time fetch, push/delete, ICS. See its README. |
| `packages/video-chat` | Video conferencing I/O: provider adapters (Google Meet, Nextcloud Talk) and dynamic links. |
## A booking
Each package's `README.md` is the detailed reference for that package; this document is
the system-level overview that ties them together.
## Two-process model
The web app and the worker are separate processes that share **one `when.yaml`** and
**one data directory**. The web app stays on the request path only long enough to do the
durable, fast work — validate, write the appointment row, enqueue a job — and returns. The
worker does everything slow or failure-prone: sending emails, pushing to external
calendars, refreshing busy times. They communicate through the database, not over HTTP.
```
appointment request ─► web: write appointment row ─► enqueue job ─► respond
│
(shared SQLite + config) ▼
worker: drain job ─► send email / sync calendar
```text
request → web: validate → save appointment → enqueue job → respond
↓
worker: calendar / video / email → record outcome
```
## Background jobs
Web and worker share `when.yaml`, `when.sqlite` and `openworkflow.sqlite`.
Web imports job contracts, not worker implementations. Producers do not start a
worker or register schedules.
Jobs run on openworkflow over `node:sqlite`. `packages/jobs` is the single source of
truth for **what** jobs exist and their input/output shapes
(`packages/jobs/src/specs.ts`): the web app enqueues `reconcile-appointment` from its
spec, and the worker provides the implementation. Both sides share types and resolve a
workflow by name, so web never imports worker code. Web and CLI initialize producer-only
clients: they neither start a worker nor persist schedules.
Booking reads a local busy-time mirror. Calendar providers expand recurrence;
CalDAV must support `CALDAV:expand`. Failed imports keep the old mirror, which is
not proof it is current. Calendar publishing writes content; video adapters handle
conferencing. Those network calls stay in the worker.
- **Durable steps.** Saved successes are reused on replay. Reconciliation checkpoints
prepared role-tagged email envelopes, then starts independent guest/host delivery
children with native retries. Native retry waits park and release the execution slot.
A crash after a remote effect but before its checkpoint can still duplicate effects
or logs: execution is at-least-once, not exactly-once.
- **Idempotency keys** dedupe enqueues for 24 hours, including matching terminal history;
they do not automatically rerun old jobs. Appointment emails key on
`appointmentId:kind:ics_sequence`; email-free reconciliation uses
`appointmentId:sync:calendar_revision`. Generation reminders use
`generate-video-chat:appointmentId:calendar_revision:notificationKind` (or `sync`
for cleanup-only work). Different notification purposes cannot consume each
other's reminder. Purge keys use sorted chain IDs. Calendar sync
uses a random key per call, and the producer awaits enqueueing only.
- **Native maintenance.** Worker `start()` persists declared UTC schedules, preserving
pause state: refresh every five minutes, pending expiry hourly, repair every ten
minutes. Startup then independently enqueues all three `{}` passes without options.
Cron is wall-clock aligned and catches up once per overdue schedule, not once per
missed tick. Pausing cron does not suppress startup passes or already queued work.
Worker scheduling and web health share conservative mirror-freshness evidence;
a push is not a refresh. See the [worker maintenance reference](../apps/worker/README.md#maintenance-and-shutdown).
- **One execution slot.** Maintenance handlers await their actual pass; sync's
`requested` result means its scan finished. There are no application maintenance
timers or coalescers. Repeated requests each get a pass and can build a backlog;
slow active maintenance blocks appointment jobs and probes. Expected provider
failures are recorded for later passes; unexpected local errors escape to bounded
native retries. A completed pass does not mean every provider succeeded.
## Background work
Normal bookings use the worker-local `notify-appointment` workflow. A booking that
needs an automatic link instead delegates its original pending/confirmed notice
to Generate. That job makes one provider attempt, publishes a ready link, and sends
the original notification with or without it. Only a notice prepared without a link
needs native video continuation and a later `video-chat-added` update. Manual Generate
is update-only. Expected initial provider failures are data, not a reason to withhold
the original notice. The initial video attempt shares one five-second deadline across
its HTTP calls; calendar sync and SMTP are outside that deadline. Notification
SMTP retries complete before video continuation. Notification preparation/role
children and their snapshot receipt have native checkpoints; no progress columns or
separate video-continuation job are needed. Physical inbox order is not guaranteed. Calendar publishing does not
create, copy, clear or adopt native conferencing. Provider video adapters own those
operations, with current-choice checks in the worker. See the
[worker appointment jobs](../apps/worker/README.md#appointment-jobs) for retry and
failure limits.
The worker has one execution slot. Active slow calls block other jobs; native
retry delays park and release the slot. Saved steps avoid repeating checkpointed
successes, but a crash before checkpointing can duplicate a remote effect.
**At-least-once, not exactly-once.**
Email action logs describe attempts; engine history determines terminal outcomes.
Failed purges retain hidden local personal data and require explicit operator review.
See [interpreting outcomes](deployment.md#interpreting-outcomes) for detailed semantics
and the [rollout runbook](deployment.md#grouped-workflow-rollout) for backup requirements.
Enqueue keys deduplicate for 24 hours, including terminal runs. They are not retry
commands. Worker stop is not queue draining. See
[updates](deployment.md#updates) before deploying
changed workflow graphs.
## Calendar I/O (off the request path)
## Time
`packages/calendar` owns all provider-reaching logic — the Google and CalDAV adapters
and every network call to an external calendar. **This code runs only in the worker.**
The worker fetches busy times into a local mirror in the database and pushes
appointments to the organizer's destination calendar. The web app reads the
already-expanded busy mirror from `@when/db` and makes no provider call. Web does import
`@when/calendar`, but only the network-free pieces (`buildIcs` for the `.ics` download
endpoint, `setLogger`); tree-shaking keeps the adapter code out of the web bundle.
Use native `Temporal` on the server and `temporal-polyfill/fns/*` in the browser.
Inject a clock into domain logic.
## Email pipeline
Displayed times follow the context's stored zone where applicable, then the `tz`
cookie, then the browser on first visit. Until the zone is known, show blank space
or a skeleton, not guessed times. `preferredTimezone.svelte.ts` owns the preference.
Lives in `apps/worker/src/email`. The flow separates assembly from rendering from
delivery:
Slot links carry an absolute instant; day links carry a date in the viewer's zone.
There is no `tz` query parameter. Duration links must name an allowed choice;
otherwise use the configured default or the current booking's reschedule length.
- **Builders** (\`builders/\*.ts\`) are pure functions: from an appointment they produce
`EmailMessage` values (an addressed `EmailContent` model + optional ICS attachment).
They do no I/O and no rendering.
- **`renderMessage`** (`render.ts`) turns one `EmailMessage` into a send-ready envelope
using Eta templates (`templates/email.html.eta`, `email.txt.eta`) and the shared
`emailTheme` tokens (`theme.ts`). It is the single place the brand logo is attached.
- **`Mailer`** (`smtp.ts`) sends an envelope over SMTP. It's built once at boot from
config and carried on the worker context (`services/context.ts`), so handlers depend on
it explicitly rather than reaching for a global — the same injection style as the
workflow `step`.
## Styling and theming (web)
## Data layer
Scoped CSS and Bits UI. Use `theme.css` tokens for colors, dimensions,
type, radii, shadows and transitions; no hardcoded colors or pixel dimensions.
Components use the shared light/dark palette, not local dark-mode patches.
`packages/db` wraps `node:sqlite` behind a small Kysely dialect (no native addons) and
owns the schema types, migrations, and runner. Migrations run on boot and are idempotent.
Both web and worker open the same `when.sqlite`; web/CLI producers and the worker
also open the shared openworkflow queue database. Worker shutdown stops config watching and
HTTP admission, awaits worker stop and HTTP closure, then closes queue and app DBs.
Worker stop waits for active work to finish or park, not for pending jobs or future retries.
Keep component copy in markup. Script is for behavior, not identifier-to-sentence
mappers. A data-derived value for genuinely repeated copy is fine.
## Configuration system
`packages/config` keeps the canonical config schema as JSON Schema
(`src/config.schema.json`) and generates everything else from it:
- **Strict TypeScript types** via `json2ts`, so defaulted fields are non-optional in code
(ajv fills the default at load).
- **A relaxed editor schema** (`config.external.schema.json`) that drops defaulted fields
from `required`, so an editor pointed at it via `# yaml-language-server: $schema=…`
doesn't flag omitted-but-defaulted fields. Point a `when.yaml` at this relaxed copy,
never the canonical one.
`${ENV_VAR}` references in any string are interpolated **before** validation, so secrets
stay in the environment, not on disk. Validation is **ajv** against the canonical schema
plus a cross-reference pass (`cross-refs.ts`) for things JSON Schema can't express (e.g.
an event type's `destination_calendar` must name a declared calendar). This project does
not use Zod — the JSON Schema is the one validator. The full `when.yaml` reference is
[`config.md`](config.md).
## Cross-cutting concerns
### Time
Never call `new Date()` or `Date.now()` inline in domain logic. Inject a `Clock` service
(`now()`) so tests can pin time. All zoned datetime math uses `@js-temporal/polyfill`
(a future Node upgrade will swap this for the native `Temporal` global).
**Viewer timezone.** Times shown to a person render in _their_ zone, resolved in this
precedence: (1) a context-specific stored zone — e.g. an appointment's \`attendee_timezone\`;
(2) the `tz` cookie — the viewer's preference, read server-side so SSR is correct and
flash-free; (3) first visit with no cookie — the client seeds it from the browser zone.
The cookie defaults to the browser zone and is replaced where a surface lets the user
choose (the scheduler). While the zone is still unknown (only the first visit, before the
cookie is set), render **blank space** where the time goes — never a guessed placeholder
time; a surface may substitute its own placeholder (e.g. a slot picker showing something).
The shared accessor is `$lib/preferredTimezone.svelte.ts` (`createPreferredTimezone` /
`getPreferredTimezone`).
**Deep links are zone-agnostic.** A slot is an absolute ISO instant (`?slot=…Z`) matched
directly against availability; a `date` (`YYYY-MM-DD`) day link opens in the viewer's zone.
There is no `tz` parameter — display always follows the viewer, so a link carries the
moment, not a zone.
### Security
- Credentials auth compares the configured username and password with `timingSafeEqual`, both
always evaluated so an early return cannot time the username. Supported in production; see
the `auth` section of `docs/config.md` for when it fits.
- Secrets are injected via `${ENV_VAR}` interpolation in `when.yaml`, never committed.
- Nothing in SQLite is encrypted at the column level. The database holds guest names,
emails and booking history in plain text. The queue also holds prepared email
envelopes, personal data, and guest capability links. Treat both databases and their
backups like `.env`; encrypt backups that leave the host.
- Never log raw request bodies, session tokens, `cancel_token` values, or secrets.
#### The guest capability model
Guests have no account, so their link _is_ their credential — a `cancel_token` in the query
string. A phone-only booking may not even have an email address to send a second factor to, so
the goal is not to make a leak impossible. It is to keep the blast radius small, bounded in
time, and reversible by the host.
- **One token per row.** A reschedule creates a new row with a new token; the old row is marked
`rescheduled` and keeps its own.
- **The live token is a master key over its chain.** A row opens to its own token or to the
chain tip's, so whoever holds the current link can read the history it grew out of.
`isViewAllowed`, `access.ts`.
- **Old links do not reach forward.** A superseded row names its successor but hands over no
token for it. A stale link therefore shows that the meeting moved and stops there; the guest
was sent the new link when it moved.
- **Everything expires.** `APPOINTMENT_VIEW_GRACE_DAYS` (7) past the appointment's end, and the
same window gates the `.ics` download. A superseded row counts those 7 days from the _move_,
not from its old slot, which would otherwise still be months away.
- **The host can revoke.** Rotating from the guest-link dialog mints a fresh token, so every
link issued so far stops resolving. Offered only on the chain tip, enforced in SQL. No email
is sent — re-delivery is the host's call, since the address may itself be the leak path.
What a leaked _live_ link still permits: cancelling, or moving the appointment — which on a
`require_approval: true` meeting drops it back to `pending` and re-arms host approval. Both
are written to the action log, both are visible to the host, and both are recoverable. That is
accepted as proportionate. View-only tokens and emailed confirmation before destructive actions
were both considered and rejected: they tax every honest guest, and neither is available at all
when no email was captured.
#### Known limits, accepted
These are understood and deliberately not fixed. Recorded so a future reader does not mistake
them for oversights.
- **`cancel_token` and bearer tokens compare with `!==`, not in constant time.** They are
full-entropy random values, which makes remote timing extraction impractical. The _password_
comparison is constant-time, because a human-chosen secret is a different problem.
- **`/public/[...file]` follows symlinks.** `resolve()` blocks `../`, but a symlink planted
inside the public directory serves its target. That directory is operator-mounted, and an
operator who can plant a symlink there can already edit `when.yaml`.
- **Bulk admin actions are not transactional.** They mutate row by row and report a combined
error afterwards, so a partial failure leaves earlier rows changed. Admin-only, low frequency,
and the outcome is visible in the list.
- **A config reload is not atomic with respect to in-flight requests.** `setState` swaps the
object, so a single request can observe two configs across two `getConfig()` calls. Reloads
are rare and the window is microseconds.
- **The app does no rate limiting.** Delegated to the reverse proxy; see `docs/deployment.md`.
### Styling and theming (web)
Vanilla CSS with Svelte's scoped `<style>` blocks. **No Tailwind.** Use [Bits UI](https://bits-ui.com)
for headless, accessible component primitives.
- **Theme variables only.** Every visual value — colors, spacing, font sizes, radii,
shadows, transitions — must use the custom properties in
`apps/web/src/lib/styles/theme.css`. Hardcoded hex/pixel values in component styles are
forbidden. Colors are semantic (`var(--primary)`, `var(--text)`, `var(--surface)`,
`var(--border)`); there is one brand hue (`--primary`) with a derived tonal scale —
don't introduce a second.
- **Branding + dark mode.** `--primary` is injected at the root layout from the user's
`when.yaml` appearance; the tonal scale derives from it. Dark mode is handled by media
queries in `theme.css`, so components need no explicit dark-mode overrides.
- **Copy lives in the markup.** Per-component user-facing strings (titles, labels, button
text) belong in the template via `{#if}` chains, not in a `<script>`-side mapper
function. `<script>` is for behavior (formatting transforms, handlers, data-derived
state), not identifier-to-string mapping. If a string repeats 3+ times in one component,
a data-derived `$derived` is fine — a mapper function is not.
## Testing
Unit tests are co-located `*.test.ts` files run by Vitest. Domain logic stays testable by
injecting dependencies — the `Clock` for time, a fake `Mailer` for email, a fake workflow
`step` for jobs — rather than reaching for globals. End-to-end tests live in
`apps/web/e2e` and run against a real browser with Playwright.
For development, see [development](development.md). For running it and backing it
up, see [deployment](deployment.md).
+58
View File
@@ -0,0 +1,58 @@
services:
when-app:
# Pin web, worker and CLI to matching published versions for controlled updates.
image: ghcr.io/eleith/when:latest
platform: linux/amd64
env_file: .env
ports:
# Localhost only. Use '3000:3000' for LAN access; restrict it with your firewall.
- '127.0.0.1:3000:3000'
volumes:
- ./config:/app/config:ro
- ./data:/app/data
- ./public:/app/public:ro
restart: unless-stopped
depends_on:
when-worker:
condition: service_healthy
healthcheck:
test:
- CMD
- node
- --eval
- fetch('http://localhost:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))
interval: 30s
timeout: 3s
start_period: 5s
retries: 3
when-worker:
image: ghcr.io/eleith/when-worker:latest
platform: linux/amd64
env_file: .env
# Health and metrics stay on the Compose network. No published worker port.
volumes:
- ./config:/app/config:ro
- ./data:/app/data
restart: unless-stopped
healthcheck:
test:
- CMD
- node
- --eval
- fetch('http://localhost:9000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))
interval: 30s
timeout: 3s
start_period: 5s
retries: 3
when-cli:
image: ghcr.io/eleith/when-cli:latest
platform: linux/amd64
profiles:
- tools
env_file: .env
volumes:
# Writable so config init can create a starter file. Other services only read it.
- ./config:/app/config
- ./data:/app/data
+338 -268
View File
@@ -1,319 +1,389 @@
# Configuration
`when.yaml` is the heart of "When" — it drives authentication, branding,
availability, calendars, email, and the meetings people can book, without an admin
database for settings. This is the complete reference; the canonical source of truth is
defined in [schema.ts](file:///home/eleith/dev/when/packages/config/src/schema.ts) (TypeBox), which generates `config.schema.json` for editors to point at.
One `when.yaml` for the web app and worker. Auth, availability, calendars, email,
and what guests can book. No settings scavenger hunt.
Point your editor at that schema for inline autocomplete and validation:
Copy the [example](when.example.yaml) to `config/when.yaml` and edit it. It
documents every field; this page explains the rules. The running app serves its
editor schema at `/schema.json`:
```yaml
# yaml-language-server: $schema=./config.schema.json
# yaml-language-server: $schema=http://localhost:3000/schema.json
```
The required top-level keys are: `auth`, `user`, `smtp`, `providers`, `schedules`, `meetings`. Optional top-level keys include `version`, `database`, `url`, and `prometheus`.
Use your app's address if different. In a checkout, the generated schema also
lives at `packages/config/src/config.schema.json`; the source of truth is the
[TypeBox schema](../packages/config/src/schema.ts).
`providers`, `schedules`, and `meetings` are **maps keyed by name, not lists**. The key _is_
the name — a provider has no `name:` field, a schedule has no `name:`, and a meeting has
neither `name:` nor `slug:` (its key is the slug, and `title:` holds the text guests see).
There is likewise **no top-level `calendars:`**: a calendar is declared inside the
`calendars:` map of the provider that serves it, which is what gives it its type and
credentials.
## File shape
```yaml
# not this # this
providers: providers:
- name: cloud cloud:
type: caldav type: caldav
calendars: calendars:
- name: work work:
provider: cloud href: 'calendars/jane/work/'
```
| Key | Required? | Purpose / default |
| ------------ | --------- | ------------------------------------------------------------------------------------------------------- |
| `auth` | Yes | One admin sign-in strategy. |
| `user` | Yes | Owner details and appearance. |
| `smtp` | Yes | Outgoing appointment email. |
| `providers` | No | Named services and their calendars; defaults to `{}`. Meetings must still reference declared calendars. |
| `schedules` | Yes | At least one named availability schedule. |
| `meetings` | Yes | At least one bookable meeting. |
| `version` | No | Config format version, not the app release. Defaults to `1`; only `1` is accepted. |
| `database` | No | SQLite paths. |
| `url` | No | Public and internal service URLs. |
| `prometheus` | No | Metrics; off by default. |
## `version`
`providers`, `schedules`, and `meetings` are **maps, not lists**. Their keys are
their names; a meeting's key is also its URL slug. Use lowercase letters, digits,
and hyphens, starting with a letter or digit. The same rule applies to calendar
keys.
The shape of this file, not the app's release. It defaults to `1` and you never need to
write it; it exists so a future change to the config layout can be detected rather than
guessed at. Only `1` is accepted today.
```yaml
version: 1
```
## Environment variable interpolation
Any string value may reference `${ENV_VAR}`. Substitution happens **before** validation,
so secrets stay in the environment rather than on disk. The `${VAR:-default}` form
supplies a fallback.
```yaml
auth:
oidc:
client_secret: '${WHEN_OIDC_CLIENT_SECRET}'
```
## `auth`
Declare **exactly one** strategy for the `/admin` interface.
```yaml
# Local username/password:
auth:
credentials:
username: 'admin'
password: '${WHEN_ADMIN_PASSWORD}' # defaults to WHEN_ADMIN_PASSWORD env var if omitted
# — or — single sign-on via OIDC:
auth:
oidc:
issuer: 'https://auth.example.com'
client_id: 'when'
client_secret: '${WHEN_OIDC_CLIENT_SECRET}'
name: 'Single Sign-On' # optional display name, defaults to 'Single Sign-On' (e.g. 'Authelia', 'Google')
```
Both are supported in production. Which one fits depends on who can reach the sign-in page:
- **`credentials`** is a good fit on a trusted network — a LAN, a VPN, or behind a proxy that
authenticates before the request arrives. The app applies **no rate limiting and no lockout**
to password attempts, so nothing slows an attacker who can reach `/signin`. Running it in
production logs one warning at startup saying so.
- **`oidc`** is the recommendation for an internet-facing deployment. Attempt throttling,
lockout, password policy, and MFA then belong to your identity provider, which is built for
them.
If you want `credentials` on a public address anyway, put the rate limiting at the reverse
proxy — see the sign-in endpoint in `docs/deployment.md`.
## `user`
The schedule owner and the appointment page's appearance.
```yaml
user:
name: 'Your Name'
email: 'you@example.com' # required, must be a valid email
timezone: 'America/New_York' # IANA timezone; defaults to the TZ env var, or UTC
appearance:
title: 'Schedule a time with me'
description: 'A little bit about me.'
app_icon_path: '/public/logo.png'
avatar_path: '/public/my-avatar.jpg'
favicon_path: '/public/favicon.svg'
opengraph_path: '/public/share-card.png'
font_name: 'Outfit' # bundled: Noto Sans, Lato, Outfit, Inter
font_path: '/public/my-font.woff2' # a woff2 registered as font_name, for a non-bundled family
primary_light_color: '#166534' # brand hue, light mode
primary_dark_color: '#34d399' # brand hue, dark mode
background_light_color: '#f5f5f5'
background_dark_color: '#0a0a0a'
text_light_color: '#171717'
text_dark_color: '#ededed'
```
`appearance` and all of its fields are optional; defaults are applied when omitted. `app_icon_path` and `favicon_path` default to a bundled spiral-calendar mark (`/assets/images/app-icon.svg`, `/assets/images/favicon.svg`), `avatar_path` defaults to `/assets/images/avatar.svg` — a placeholder avatar generated from `user.name` — and `opengraph_path` defaults to `/assets/images/opengraph.png`, a share card generated from the rest of the appearance. Every `*_path` here is a root-relative path to an asset this app serves; remote URLs are rejected. `primary_light_color` and `primary_dark_color` set the brand hue for light and dark modes, and a muted tonal scale is derived from each; the `background_*_color` and `text_*_color` fields likewise override the light/dark surface and text colors. To override any of them, place custom assets in `./public/` (a sibling of `config/` and `data/`, overridable with `WHEN_PUBLIC_DIR`) and reference them as `/public/...`.
## `smtp`
**Required.** The worker sends all appointment emails over this server; the appointment system
relies on it.
```yaml
smtp:
host: 'smtp.example.com'
port: 587 # defaults to 587
username: 'you@example.com'
password: '${WHEN_SMTP_PASSWORD}'
from: 'bookings@example.com' # optional; defaults to noreply@<your url.app domain>
```
`from` is the address on every email and the organizer on guest-facing calendar invites, so
the host's own address is never exposed. It must be one your SMTP server may send from. The
display name always comes from `user.name`.
## `providers`
External services and the calendars they serve, each keyed by the name you refer to it
by. Three `type`s are supported: `google`, `caldav`, and `nextcloud`.
A calendar is declared inside the provider that serves it, so it needs no `provider`
reference and no `type` of its own — both are implied by where it sits. Calendars are
conflict sources (busy times) and/or appointment destinations.
There is no top-level `calendars`, no provider or schedule `name`, and no meeting
`name` or `slug` field. Calendars belong inside their provider:
```yaml
providers:
my-google-service:
type: 'google'
client_id: '${WHEN_PROVIDER_GOOGLE_SERVICE_CLIENT_ID}'
client_secret: '${WHEN_PROVIDER_GOOGLE_SERVICE_CLIENT_SECRET}'
refresh_token: '${WHEN_PROVIDER_GOOGLE_SERVICE_REFRESH_TOKEN:-}' # from /admin; empty = not connected
calendars:
personal: # referenced by meetings
id: 'primary' # the Google calendar ID
sync:
refresh_every_minutes: 10 # minutes between busy-time refreshes (default 10)
my-caldav-service:
type: 'caldav'
cloud:
type: caldav
url: 'https://cloud.example.com/remote.php/dav/'
username: 'jane'
password: '${WHEN_PROVIDER_CALDAV_SERVICE_PASSWORD}'
username: jane
password: '${WHEN_CALDAV_PASSWORD}'
calendars:
work:
href: 'calendars/jane/work/' # joined to the provider url
shared:
href: 'https://other.example.com/dav/shared/' # ...or a full URL of its own
my-nextcloud-service:
type: 'nextcloud'
url: 'https://nextcloud.example.com/'
username: 'jane'
password: '${WHEN_PROVIDER_NEXTCLOUD_SERVICE_PASSWORD}'
calendars: {}
href: 'calendars/jane/work/'
```
**Calendar keys must be unique across every provider**, since meetings reference them by
name alone. A CalDAV or Nextcloud calendar names its location with `href`, which is either joined to
the provider's `url` or a full URL of its own. `sync.refresh_every_minutes` is optional
everywhere.
Unknown options are rejected. References to schedules, calendars, providers, and
form fields are checked at load time.
A Google provider's `refresh_token` is the one credential you cannot write from scratch:
Google only issues it through a browser consent round-trip. Connect the provider from
`/admin`, which shows the token once, then store it in an env var and reference it here
like any other secret. It is optional and defaults to empty — an empty token simply reads
as not connected, so a provider works up to the point of actually reaching Google. See
[deployment](deployment.md) for the full flow, including the container recreate that a new
env var needs.
## 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.
- `${VAR}` fails loading if the variable is unset.
- `${VAR:-fallback}` uses the fallback if the variable is unset, not if it is empty.
- `$$` produces a literal `$`.
For example: `password: '${WHEN_SMTP_PASSWORD}'`. Deployment variables such as
`CONFIG_PATH`, `AUTH_SECRET`, and `ORIGIN` are covered in
[deployment](deployment.md#environment-variables).
## `auth`
Choose **exactly one** strategy for `/admin`.
```yaml
auth:
credentials:
username: admin
# password defaults to ${WHEN_ADMIN_PASSWORD}
```
Or replace it with OIDC:
```yaml
auth:
oidc:
issuer: 'https://auth.example.com'
client_id: when
client_secret: '${WHEN_OIDC_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`.
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.
- **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.
## `user`
| Field | Default | Meaning |
| ------------ | ------------ | ---------------------------------------------------------------------- |
| `name` | Required | Owner's display name. |
| `email` | Required | Owner's valid email address. |
| `timezone` | `${TZ:-UTC}` | IANA zone, such as `America/New_York`. Schedule windows use this zone. |
| `appearance` | `{}` | Optional overrides below. |
### Appearance
Every field under `user.appearance` is optional.
| Field | Default |
| ------------------------ | --------------------------------------------------- |
| `title` | `if not now, when?` |
| `description` | `find some time and we can meet` |
| `app_icon_path` | `/assets/images/app-icon.svg` |
| `avatar_path` | `/assets/images/avatar.svg` |
| `favicon_path` | `/assets/images/favicon.svg` |
| `opengraph_path` | `/assets/images/opengraph.png` |
| `font_name` | `Noto Sans` |
| `font_path` | Unset; custom woff2 file registered as `font_name`. |
| `primary_light_color` | `#166534` |
| `primary_dark_color` | `#34d399` |
| `background_light_color` | `#f5f5f5` |
| `background_dark_color` | `#0a0a0a` |
| `text_light_color` | `#171717` |
| `text_dark_color` | `#ededed` |
The default icon and favicon are bundled calendar marks. The default avatar is
generated from `user.name`; the default share card uses the appearance settings.
Bundled font families are Noto Sans, Lato, Outfit, and Inter.
All `*_path` values must be root-relative paths served by this app. Remote URLs
are rejected. Put custom files in `./public/`, alongside `config/` and `data/`,
and reference them as `/public/logo.png`. `WHEN_PUBLIC_DIR` overrides that directory.
Colors accept three- or six-digit hex values. Each primary color generates a
muted tonal scale for its mode; background and text colors override the surfaces
and text.
## `smtp`
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. |
`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`.
## `providers`
Named services, each containing a `calendars` map. See the
[example](when.example.yaml) for complete provider blocks.
| `type` | Required fields | Optional fields |
| ----------- | ----------------------------- | ----------------------------------------------------------- |
| `google` | `client_id`, `client_secret` | `refresh_token` (default `''`), `calendars` (default `{}`). |
| `caldav` | `url`, `username`, `password` | `calendars` (default `{}`). |
| `nextcloud` | `url`, `username`, `password` | `calendars` (default `{}`). Also supports Talk video links. |
For CalDAV, `url` is the DAV endpoint. For Nextcloud, it is the instance base URL.
### Calendars
Calendar keys must be **unique across all providers**: meetings reference them by
name alone. They supply busy times, receive appointments, or both.
| Provider | Calendar field | Meaning |
| ------------------ | ---------------------------- | ----------------------------------------------------------- |
| Google | `id` (required) | Google calendar ID, such as `primary`. |
| CalDAV / Nextcloud | `href` (required) | Path joined to the provider's `url`, or a full HTTP(S) URL. |
| All | `sync.refresh_every_minutes` | Busy-time refresh interval; integer ≥ 1, default `10`. |
A calendar needs no `type` or `provider` field; its parent supplies both.
### Google connection
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).
## `schedules`
Weekly schedules defining availability slots, keyed by the name meetings refer to.
`weekly` is a list of rules, each naming the `days` it applies to and a `from`/`to` window (24-hour
`HH:MM`, in `user.timezone`). Availability is the union of all rules.
Each named schedule has a `weekly` list. Omit `weekly` to use Monday–Friday,
09:00–17:00. For example, `standard: {}` is a complete schedule.
Each rule needs `days`, `from`, and `to`:
```yaml
schedules:
standard: # the key is the name meetings reference
weekly: # optional; defaults to Monday–Friday 09:00–17:00 when omitted
- days: [mon, tue, wed, thu]
standard:
weekly:
- days: [mon, tue, wed, thu, fri]
from: '09:00'
to: '12:00'
- days: [mon, tue, wed, thu, fri]
from: '13:00'
to: '17:00'
# For multiple windows in a day (e.g. a lunch break), repeat the day:
- days: [fri]
from: '09:00'
to: '13:00'
- days: [fri]
from: '14:00'
to: '17:00'
# A day named by no rule has no availability (here: sat, sun).
```
- Days are `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`.
- Times are 24-hour `HH:MM` in `user.timezone`; `from` must be earlier than `to`.
- Availability is the union of all rules. Repeat days for split windows.
- Days absent from every rule are unavailable. An explicit `weekly` list cannot be empty.
## `meetings`
The meetings people can book. **The key is the URL slug** — `30-min-chat` is booked at
`/schedule/30-min-chat`. `title`, `schedule`, and `booking_calendar` are required — every
meeting names what it is, where it books, and when. `duration_minutes` (30),
`require_approval` (true), and `show_slots` (false) all default; everything else is optional.
The key is the booking URL slug: `chat` gives `/schedule/chat`.
```yaml
meetings:
chat: # URL slug: /schedule/chat
title: '30-minute chat'
duration_minutes: 30 # the default length offered (default 30)
additional_duration_minutes: [15, 60] # further lengths the guest may pick
description: 'A quick intro call.'
visibility: 'public' # 'public' (default) or 'unlisted' (kept off the homepage; the link still works)
require_approval: true # true (default, host approves each booking) or false (instant)
additional_busy_calendars: ['personal'] # further calendars to check for conflicts; booking_calendar is always checked (default [])
booking_calendar: 'work' # required: where the appointment is written
schedule: 'standard' # required: references a schedules name
location: 'Office Room 101' # a static URL, address, or phone number (optional)
video_chat: # provider and dynamic link attachment rules (optional)
provider: 'my-nextcloud-service' # references a nextcloud or google provider name
attach: # rules for attaching link (optional; defaults to auto: true)
auto: true # auto: true (automatic) or auto: false (admin generates on demand)
# when: # or attach conditionally based on guest form answers
# - field: 'loc'
# equals: 'Video Call'
note: 'Please review materials prior to the call.' # a host note shown to guests (optional)
form_fields: # custom booking questions (optional)
- name: 'name'
type: 'guest_name'
label: 'Your Name'
required: true
- name: 'email'
type: 'guest_email'
label: 'Your Email'
required: true
- name: 'loc'
type: 'event_location'
label: 'Preferred Location'
required: true
- name: 'goals'
type: 'paragraph'
label: 'What would you like to discuss?'
required: false
- name: 'contact_method'
type: 'choice'
label: 'How should we reach you?'
required: true
choices: ['phone', 'email']
- name: 'phone'
type: 'phone'
label: 'Your phone number'
required: true # required only while shown
show_when: # show only when every condition holds (AND)
- field: 'contact_method' # references an earlier field by name
equals: 'phone' # a single value, or a list for "one of"; omit to mean "filled at all"
# Scheduling rules:
start_times_every_minutes: 30 # slots snap to this boundary (default 30; independent of the lengths offered)
notice_minutes: 120 # minutes of lead time required (default 120)
booking_window_days: 60 # days bookable into the future (default 60)
padding_before_minutes: 0 # minutes padded before a meeting (default 0)
padding_after_minutes: 0 # minutes padded after a meeting (default 0)
daily_booking_limit: null # cap on meetings per day; null = unlimited (default null)
chat:
title: 'A quick chat'
schedule: standard
booking_calendar: work
duration_minutes: 30
additional_duration_minutes: [15, 60]
```
### Custom Form Fields and Video Chats
### Booking options
Rather than rigid location structures, meetings are customized using:
| Field | Default | Meaning |
| ----------------------------- | -------- | ---------------------------------------------------------------------------------------- |
| `title` | Required | Text guests see. |
| `schedule` | Required | Declared schedule name. |
| `booking_calendar` | Required | Declared calendar for confirmed bookings; always checked for conflicts. |
| `duration_minutes` | `30` | Default guest-selected length; positive integer. |
| `additional_duration_minutes` | `[]` | Other positive integer lengths guests may choose. |
| `description` | Unset | Brief description. |
| `visibility` | `public` | `unlisted` hides it from the homepage, **not** from anyone with its link. |
| `require_approval` | `true` | Host approves bookings; `false` confirms instantly. |
| `additional_busy_calendars` | `[]` | Other calendar names to check for conflicts. |
| `show_slots` | `false` | `true` draws slot buttons; `false` uses a bare drag timeline snapping to the same slots. |
| `location` | Unset | Static address, phone number, or link. |
| `note` | Unset | Host note shown to guests after booking. |
| `video_chat` | Unset | Dynamic video link settings below. |
| `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. |
- **Fixed Location**: A static string configured via `location`.
- **Dynamic Video Chat**: Configured under `video_chat` with `provider` (e.g. `my-nextcloud-service` or `my-google-service`) and optional `attach` rules (`auto: true|false` or `when: [...]` conditions based on form answers). Dynamic links (like Nextcloud Talk rooms or Google Meet URLs) are generated automatically upon booking or on-demand by the host.
- **Custom Questions**: Configured via `form_fields`. Every form **must** include exactly one `guest_name` field (with `required: true`). Optional special field types include `guest_email` and `event_location`. General text fields, numbers, phone numbers, paragraphs, and choices are also supported. A field may be shown conditionally with `show_when`: a list of `{ field, equals }` conditions (all must hold) referencing earlier fields. Omit `equals` to require only that the referenced field is filled, or give it a list to accept any of several values. A field hidden by `show_when` is never required and its answer is not recorded.
Duration choices are deduplicated and sorted numerically. New bookings start with
`duration_minutes`, rescheduling starts with the booked length, and a valid
`duration` link overrides either. The first choice is not necessarily the default.
Hosts also get preset lengths; guests do not gain those choices except to retain
a booked length. The slot step does not change with the selected duration.
A step shorter than a duration produces overlapping candidate slots, visible
when `show_slots` is enabled.
### Custom forms
Without `form_fields`, the form asks for a required guest name, required guest
email, and optional notes. Setting `form_fields` **replaces** that default form.
Supply 1–10 fields, with unique `name` values. Each needs `name`, `type`, and
`label`; `required` defaults to `false`, and `show_when` is optional.
| Type | Rules |
| -------------------------------------- | --------------------------------------------------------------------------- |
| `guest_name` | Exactly one, with `required: true`. |
| `guest_email` | At most one; optional in a custom form. |
| `event_location` | At most one. With `choices`, guests pick a location; without, they type it. |
| `choice` | Requires a non-empty `choices` list of non-empty strings, in display order. |
| `text`, `number`, `phone`, `paragraph` | General questions; `choices` is not accepted. |
This is a `form_fields` block inside a meeting:
```yaml
form_fields:
- name: name
type: guest_name
label: 'Your name'
required: true
- name: contact
type: choice
label: 'How should we reach you?'
choices: [email, phone]
required: true
- name: email
type: guest_email
label: 'Your email'
required: true
show_when:
- field: contact
equals: email
- name: phone
type: phone
label: 'Your phone number'
required: true
show_when:
- field: contact
equals: phone
```
Every `show_when` condition must hold (AND). Conditions reference **earlier** fields:
- `equals: phone` matches one value.
- `equals: [phone, email]` accepts any listed value.
- Omitting `equals` tests whether the field is filled.
For a `choice` controller, matched values must exist in its `choices`. Hidden
fields are never required, and their answers are not recorded.
### Video links
`video_chat.provider` names a declared `google` or `nextcloud` provider, not a
calendar. Google Meet also requires a Google booking calendar. Generic CalDAV
providers do not generate video links.
Choose one attachment rule under `video_chat.attach`:
| Rule | Behavior |
| ------------------------ | -------------------------------------------------------------- |
| Omitted, or `auto: true` | Generate a link automatically on booking. |
| `auto: false` | Host generates the link on demand. |
| `when: [...]` | Generate automatically when every form-answer condition holds. |
Use `auto` **or** `when`, not both. `when` is a non-empty list using the same
`field` / `equals` conditions as forms, referencing fields in this meeting.
For example, inside a meeting with a `contact` field:
```yaml
video_chat:
provider: nextcloud-service
attach:
when:
- field: contact
equals: video
```
A `choice` field must include the matched value (`video` here). Links are generated
per appointment as Google Meet URLs or Nextcloud Talk rooms.
## `database`
On-disk SQLite paths. Relative paths resolve against the config directory's parent — the
deployment root that holds `config/` and `data/` as siblings — so the web and worker
(loading the same `when.yaml`) open the same files.
| Field | Default | Contents |
| ------- | ---------------------------- | -------------------------------------------------- |
| `app` | `./data/when.sqlite` | Appointments, mirrored busy times, service status. |
| `queue` | `./data/openworkflow.sqlite` | Background job queue. |
```yaml
database:
app: './data/when.sqlite' # appointments, busy times, service status (default)
queue: './data/openworkflow.sqlite' # background job queue (default)
```
Relative paths resolve against the config directory's **parent**, not the shell's
working directory. With `config/when.yaml`, `config/` and `data/` are siblings.
Web and worker must load the same config and share these files.
`WHEN_DATABASE_PATH` and `WHEN_QUEUE_DB_PATH` override the respective YAML paths.
Treat both databases and their backups as sensitive; see
[persistence](deployment.md#persistence).
## `url`
Public URLs and internal network endpoints for service-to-service communication.
| Field | Default | Used for |
| ---------- | ---------------------------------- | ------------------------------------------------------------------------------------- |
| `app` | `${ORIGIN:-http://localhost:5173}` | Public base URL, including scheme; links in emails and calendar events. |
| `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. |
```yaml
url:
app: 'https://book.example.com' # public base URL (with scheme); used in emails + calendar events (defaults to ${ORIGIN})
internal: 'http://when-app:3000' # base URL the worker uses to reach the web app internally (defaults to ${WHEN_URL_INTERNAL}; falls back to `app`)
worker: 'http://when-worker:9000' # base URL the web app uses to reach the worker internally for telemetry and metrics (default 'http://when-worker:9000')
```
Usually you can omit `app` entirely. It defaults to the `ORIGIN` env var, which
`adapter-node` requires anyway to run behind a reverse proxy (see
[`deployment.md`](deployment.md)) — so `ORIGIN` alone keeps both in sync.
Usually, set `ORIGIN` and leave `url.app` alone. The web adapter needs `ORIGIN`
behind a reverse proxy anyway. See [deployment](deployment.md#environment-variables).
## `prometheus`
Configuration for Prometheus metrics collection.
| Field | Default | Meaning |
| --------- | ------------------------- | ----------------------------------------- |
| `enabled` | `false` | Enable metrics collection and `/metrics`. |
| `token` | `${WHEN_METRICS_TOKEN:-}` | Bearer token required for scraping. |
```yaml
prometheus:
enabled: false # Whether metrics collection and endpoint are active (default: false)
token: '${WHEN_METRICS_TOKEN:-}' # Bearer token for scraping metrics (default: ${WHEN_METRICS_TOKEN})
```
When disabled, `/metrics` returns 404. When enabled without a non-empty token,
it returns 500, not public metrics. Scrape with `Authorization: Bearer <token>`.
See [operating endpoints](deployment.md#inspect-logs).
+74 -380
View File
@@ -1,398 +1,92 @@
# Deployment
Running "When" in production. The bundled `apps/web/docker-compose.yml` runs both
services (web + worker) against one `when.yaml` and data directory. See
[`README.md`](../README.md) for the quick start and [`config.md`](config.md) for the
config reference.
Use Docker Compose with the [sample](compose.yaml). There are other ways; you're
welcome to them.
## Environment variables
Start with the [quick start](../README.md#get-started) and
[example config](when.example.yaml).
Both compose files deliver the environment from `apps/web/.env` via `env_file:` — start
from the tracked `apps/web/.env.example`. Nothing is baked into the image, so the same
image runs against whatever `.env` you bring. Compose's auto-loaded `.env` only
substitutes `${VAR}` inside the compose YAML itself; `env_file:` is what populates the
container's environment.
## start up
A few variables are read **directly** by the app from the environment:
| Variable | Required | Notes |
| ------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AUTH_SECRET` | always | Auth.js JWT signing secret. 32+ random bytes, base64-encoded (`openssl rand -base64 32`). |
| `ORIGIN` | behind a proxy | Public base URL, no trailing slash. `adapter-node` otherwise derives the origin from the inbound `Host` header, which a TLS-terminating proxy makes `http://…` while the browser sends `https://…` — SvelteKit's CSRF check then rejects every form POST with a 403. Also the default for `url.app`, so setting it here is enough. |
`ORIGIN` and `url.app` in `when.yaml` are the same value — your public base URL. Set
`ORIGIN` and leave `url.app` unset: `adapter-node` reads `ORIGIN` from the environment
before any app code runs, so it can only travel in that direction. If you do set both,
keep them identical; a mismatch means correct links in emails and 403s on every form.
Everything else is a **secret referenced from `when.yaml`** via `${ENV_VAR}`
interpolation — the variable _names_ are whatever your config uses. The table below lists
the names both `config init` and `config/when.example.yml` write; `<NAME>` is the
provider's key upper-cased, with `-` as `_` (so the `caldav-service` provider reads
`WHEN_PROVIDER_CALDAV_SERVICE_PASSWORD`). You set these values yourself.
| Variable | Needed when | Notes |
| ------------------------------------ | --------------------------- | --------------------------------------------------------------------------------------------- |
| `WHEN_ADMIN_PASSWORD` | credentials auth | Plain text password of the admin (defaults to this if omitted in config). |
| `WHEN_OIDC_CLIENT_SECRET` | OIDC auth | OIDC provider client secret. |
| `WHEN_SMTP_PASSWORD` | always | SMTP password — SMTP is required. |
| `WHEN_PROVIDER_<NAME>_PASSWORD` | a CalDAV/Nextcloud provider | CalDAV / Nextcloud provider password. |
| `WHEN_PROVIDER_<NAME>_CLIENT_SECRET` | a Google provider | Client secret from Google Cloud. |
| `WHEN_PROVIDER_<NAME>_REFRESH_TOKEN` | a connected Google provider | Refresh token. You do not invent this one — `/admin` shows it after you authorize. See below. |
The worker also honors a few operational variables:
| Variable | Notes |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `CONFIG_PATH` | Path to `when.yaml` (default `/app/config/when.yaml` in prod, `<cwd>/config/when.yaml` otherwise). |
| `WHEN_DATABASE_PATH` / `WHEN_QUEUE_DB_PATH` | Optional overrides for the config's `database.app` / `database.queue`. |
| `WHEN_PUBLIC_DIR` | Override the branding-overrides dir (default `<root>/public`, i.e. `/app/public` in the container). |
| `PORT` | Worker health-server port (default `9000`). |
| `WHEN_URL_INTERNAL` | Default for `url.internal` (how the worker reaches the web app internally). Baked into the Docker images per target. |
## Docker
Make `data/` writable by container UID 1000, or SQLite won't start.
```sh
cp apps/web/.env.example apps/web/.env # then fill it in
docker compose -f apps/web/docker-compose.yml up -d
docker compose up -d
```
`.env` must exist — compose refuses to start without the file it is told to load.
Web listens on 3000.
This brings up the web app (appointment page at \`/\`, admin at \`/admin\`) and the worker
(calendar sync + email delivery). Both run database migrations relevant to their role on
boot.
## Behind a reverse proxy
The app speaks plain HTTP on port 3000 and terminates no TLS of its own, so any
internet-facing deployment puts a proxy in front. Below is the split of who owns what.
### Required of the deployment
- **Set `ORIGIN`** to the public base URL. Without it the app derives its origin from the
inbound `Host` header, which a TLS-terminating proxy reports as `http://…` while the
browser sends `Origin: https://…`; SvelteKit compares the two and rejects **every** form
POST with a 403 — booking, cancel, reschedule, sign-in, and the admin bulk actions.
- **The proxy must set `Host` from its own configuration** and must not forward a
client-supplied value. With `ORIGIN` set the app ignores `Host` when building the request
URL, which is also what makes Auth.js's `trustHost: true` safe — OIDC callback URLs are
then built from a pinned origin rather than an attacker-influenceable header.
- **Point the proxy's upstream health check at `GET /healthz`** on the web app.
You do **not** need `PROTOCOL_HEADER` or `HOST_HEADER`: those exist to reconstruct the
origin from forwarded headers, and a set `ORIGIN` takes precedence over both. Likewise
`ADDRESS_HEADER` and `XFF_DEPTH` — the app never reads the client address.
### Owned by the proxy
- **TLS termination** and the HTTP → HTTPS redirect.
- **`Strict-Transport-Security`.** Deliberately not sent by the app; it is meaningless on
the plaintext hop and belongs to whatever terminates TLS.
- **Rate limiting.** The app does none. The endpoints worth a rule are the unauthenticated
ones: `POST /schedule/<slug>` (each success writes a row _and_ sends mail over your SMTP
credentials, so abuse costs sender reputation), `POST /appointment/<id>` and
`POST /appointment/<id>/reschedule`, and the sign-in POST when `auth.credentials` is in
use — the app performs no lockout or throttling on password attempts. It warns once at
startup when credentials auth runs in production; a rule here, or OIDC instead, is the
answer. See the `auth` section of `docs/config.md`.
- **Response caching** for `/assets/images/opengraph.png` and `/assets/images/avatar.svg`,
which are rendered per request, and for `/public/*`.
- **Request body limits**, if you set any: keep them at or above the app's own
`BODY_SIZE_LIMIT` (512K by default) so oversized requests produce the app's error rather
than the proxy's.
### Security headers
The app sends these itself, so it is safe without a proxy in front of it:
- `Content-Security-Policy`, including `frame-ancestors 'none'` — which supersedes
`X-Frame-Options`, so you do not need that header at all.
- `Referrer-Policy: strict-origin-when-cross-origin`.
- `X-Content-Type-Options: nosniff`.
**Never set `Content-Security-Policy` at the proxy.** Two CSP headers are not
last-one-wins — the browser enforces both and the page gets their intersection. SvelteKit
mints a per-request nonce for its hydration script and the booking page carries an injected
inline `<style>` for the configured theme, neither of which a proxy can account for, so a
second policy will break rendering.
The other two are yours to duplicate if your standard vhost sets them. A proxy that replaces
headers wins outright; one that appends (bare nginx `add_header`) leaves two values and the
browser takes the last valid one. Either way the app's value is the fallback, not the winner,
so make sure yours is at least as strict. For `Referrer-Policy` that matters: guest
appointment links carry their access token in the query string, and anything weaker than
`strict-origin-when-cross-origin` leaks a working credential in the `Referer` of an outbound
click.
The app's policy allows no remote origin at all. Every `appearance.*_path` is a root-relative
path this app serves, so custom branding goes in `./public/` and is referenced at `/public/…`.
### Ports
Publish only the web app's 3000. The worker's port (`9000` by default) is internal-only and
the bundled `docker-compose.yml` correctly does not publish it: its `GET /healthz` is
unauthenticated by design, for the container liveness check. Its `/metrics` is bearer-gated
and served only when `prometheus.enabled` is true, but the port should still never be
reachable from outside the compose network.
The web app's `/metrics` is bearer-gated the same way. If you scrape it through the proxy,
keep it off the public vhost or restrict it by source address — the bearer token is the only
thing in front of it.
## Persistence
State is two SQLite files under the data directory the compose file mounts
(`./data` → `/app/data`):
- `when.sqlite` — appointments, mirrored busy times, and service status (shared by web + worker).
- `openworkflow.sqlite` — the background job queue (shared by web/CLI producers and worker).
Branding overrides live in a separate `public/` directory mounted alongside
(`./public` → `/app/public`, overridable with `WHEN_PUBLIC_DIR`); the web app serves
them at `/public/...`. The bundled defaults under `/assets/*` are baked into the image
and need no mount.
Back up both databases consistently using SQLite-aware backup handling, not live file
copies that omit committed WAL data. Include `when.yaml`, its environment secrets, and
`public/` overrides separately. Database paths are configurable under `database`.
Both databases are sensitive: the queue includes prepared email envelopes, personal data
and guest capability links. Encrypt off-host backups and restrict local access.
## Grouped workflow rollout
Deploy web, worker and CLI as one stopped, grouped release. Do not deploy intermediate
PR images automatically; release/version/tag operations require separate authorization.
Application tests and the read-only drain/backup procedure are the gates, not an
assumption of old/new queue compatibility.
1. **Verify the release.** Run affected jobs/worker/web/CLI checks, lint, coverage and
builds, workspace checks and browser regressions. Confirm both CI paths execute the
co-located integration tests. Keep the currently deployed artifacts available.
2. **Stop producers**, including web, CLI commands and any other enqueuers. Let the
**old worker** drain every unfinished old run. Worker shutdown alone is not draining:
future pending jobs, parked retries/parents, running jobs and legacy `sleeping` work
all count. Inspect the old queue read-only; do not initialize the new jobs client,
since connection runs migrations. If recurring dispatch exists, pause it with the
matching deployed API so the queue can drain.
3. **Halt if it cannot drain.** Require an explicit operator retry/cancel/reset decision
after reviewing local and remote state. Never automatically wipe/reset a queue, even
an apparently empty one, edit checkpoints, or resume changed old application graphs.
Inspect again after any authorized action; cancelling a parent need not cancel its
already-started children.
4. **Stop the old worker**, recheck that no unfinished runs remain, then take a consistent
paired app/queue backup while all writers stay stopped. Include committed WAL data
with SQLite-aware handling and verify restoration on copies (examples below).
5. **Deploy the grouped artifacts once**, against the same config and database paths,
with no old worker or competing maintenance timers. Native `worker.start()` persists
schedules and preserves pause; startup then enqueues all three `{}` passes without
options, independently of pause. Inspect startup passes, schedules, service status
and queue progress. Cron catches up once per overdue schedule and can coexist with
startup work. Repeated scans are not coalesced; slow active maintenance blocks other
jobs at concurrency one, while native retry waits park. Do not replay old graphs or
automatically rerun terminal history; dedup may return an existing terminal run.
6. **Rollback only with evidence.** Use a proven compatible binary, or obtain explicit
approval for restoring the paired backups and losing all later writes. Old binaries
are not assumed compatible with migrated queues. Stop every writer before a paired
restore; restore into clean destinations without stale WAL/SHM sidecars. Remote
calendar/email effects are not undone by restoring SQLite.
### Read-only drain inspection
Set `QUEUE` to the actual old queue path. With the SQLite CLI, inspect the schema first:
## inspect logs
```sh
sqlite3 -readonly "$QUEUE" '.schema workflow_runs'
docker compose logs --tail=100 -f
```
The installed `0.11.0` schema uses `workflow_runs`, with `namespace_id`, `id`,
`workflow_name`, `status`, and `available_at`. Verify these columns against the deployed
schema; a mismatch requires investigation, not opening it with a newer client. Installed
public status types/terminal logic recognize `completed`, legacy `succeeded`, `failed`,
and **`canceled`** (one l) as terminal. `pending`, `running`, and legacy `sleeping` are
unfinished. Unknown statuses halt the rollout, even if they look terminal.
Visit `/healthz` for liveness and `/admin/health` for 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.
| Variable | Used for |
| ------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `AUTH_SECRET` | Required Auth.js session-signing secret. Generate 32 random bytes with `openssl rand -base64 32`. |
| `ORIGIN` | Public base URL, no trailing slash. Set it behind a proxy or form POSTs can fail with 403. Also the default for `url.app`. |
Other secret names come from `${ENV_VAR}` references in your config. The example
uses:
| 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. |
Advanced overrides:
| 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. |
## 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.
## updates
Update web, worker and CLI together. Stop producers, drain old jobs with the old
worker, then stop it and back up both databases before starting new versions.
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
sqlite3 -readonly -header -column "$QUEUE" <<'SQL'
PRAGMA query_only = ON;
SELECT status, COUNT(*) AS runs FROM workflow_runs GROUP BY status;
SELECT namespace_id, id, workflow_name, status, available_at
FROM workflow_runs
WHERE status NOT IN ('completed', 'succeeded', 'failed', 'canceled')
ORDER BY namespace_id, created_at, id;
SQL
docker compose run --rm when-cli config validate --check-env
```
Proceed only when the second query returns no rows and every observed status is known.
There is deliberately **no due-time filter**: future pending work is unfinished. Inspect
all namespaces and child runs, not just top-level appointment runs. Do not dump inputs,
envelopes, errors or capability links into shared logs.
### Paired backup and restore check
With producers and worker stopped, set `APP`, `QUEUE`, and `BACKUP` to the real paths
and a fresh restricted backup directory. For ordinary paths without single quotes:
```sh
mkdir -m 700 "$BACKUP"
sqlite3 -readonly "$APP" ".backup '$BACKUP/when.sqlite'"
sqlite3 -readonly "$QUEUE" ".backup '$BACKUP/openworkflow.sqlite'"
mkdir -m 700 "$BACKUP/restore-check"
cp "$BACKUP/when.sqlite" "$BACKUP/restore-check/when.sqlite"
cp "$BACKUP/openworkflow.sqlite" "$BACKUP/restore-check/openworkflow.sqlite"
sqlite3 -readonly "$BACKUP/restore-check/when.sqlite" 'PRAGMA integrity_check;'
sqlite3 -readonly "$BACKUP/restore-check/openworkflow.sqlite" 'PRAGMA integrity_check;'
```
Both integrity checks must report `ok`. Compare restored appointment counts and queue
status counts to the stopped sources, and rerun drain inspection on the restored queue.
SQLite `.backup` includes committed WAL state; do not copy live main files alone or
remove source WAL files to make backups look clean. Because every writer is stopped,
the sequential backups form a consistent pair. Keep that pair together; test copies
must never reach real providers or become the running deployment.
### Interpreting outcomes
An email action `failed` is an **observed provider attempt**, possibly followed by retries
and recovery; local errors are not provider-failure observations. `done` requires a
nonempty intended set where every delivery reported `sent` and the row is still eligible
at the final write. Mixed/all skipped sets and missing/purged rows get no summary.
Engine history is authoritative for terminal failure; no aggregate terminal email-failure
summary is synthesized. An already-started sibling can continue independently. SMTP
service status is the **last recorded physical attempt outcome**, not an aggregate
recipient/job result. Its timestamps describe the send attempt, not a later status-write
retry. A delayed success write can overwrite a newer failure and move the recorded
timestamps backwards; this is not a time-ordered health history.
Handler metrics include replays/suspensions; email metrics count
physical attempt outcomes. Crashes before checkpoints can duplicate effects or log
entries: this is at-least-once execution, not an exactly-once ledger.
A failed purge retains the current and later hidden `purged` rows, including local
personal data. Earlier successful deletions remain deleted. Web selection skips
already-purged rows, so clicking purge again is not promised recovery. Review retained
rows, engine history and remote state before choosing explicit operator cleanup; there
is no automatic terminal rerun or remote-orphan fallback. A completed maintenance pass
likewise does not guarantee every provider succeeded. See the
[worker README](../apps/worker/README.md#maintenance-and-shutdown) for cadence and the
[jobs README](../packages/jobs/README.md#schedule-management) for public schedule management.
## Config reloads
`when.yaml` is watched, and most edits are applied in place. Changes under `auth` or `database`
cannot be — the auth provider and the database connections are built once at boot — so the app
logs `config change requires a restart` and exits **0**, expecting the supervisor to bring it
back. Compose does, via `restart: unless-stopped`.
If you run the built server under something else, make sure a clean exit restarts it. A systemd
unit with `Restart=on-failure` will _not_, and the app will simply stop after an `auth` or
`database` edit. Use `Restart=always`.
## Connecting a Google provider
Google needs a browser round-trip, so this one credential cannot come from `when.yaml`
alone. The flow ends by handing it to you rather than storing it:
1. Configure the provider's `client_id` and `client_secret`, and register
`<your app url>/admin/services/google/callback` as an authorized redirect URI in Google
Cloud. `/admin/health` shows the exact URI to paste.
2. Press **Connect** on `/admin/health` and complete Google's consent screen.
3. The callback shows the refresh token **once**. Put it in `.env` as
`WHEN_PROVIDER_<NAME>_REFRESH_TOKEN`, and reference it from the provider's
`refresh_token` field in `when.yaml`.
4. Recreate the containers: `docker compose -f apps/web/docker-compose.yml up -d`.
That last step is the one that catches people. **`docker compose restart` is not enough.**
Compose reads `.env` when it _creates_ a container, so restarting the container you already
have keeps the environment it was born with. Only `up -d` recreates it. Under systemd or
another supervisor, the equivalent is whatever re-reads your environment file — a plain
service restart may not.
Because `when.yaml` is watched but `.env` is not, editing the config first is the wrong
order: the watcher reloads, the env var is still unset, and interpolation fails with
`when.yaml references unset environment variables`. The reload is rejected and the old
config kept, which looks like nothing happening. Two ways to avoid it — keep the
`refresh_token` line in `when.yaml` from the start using the `:-` fallback form that
`config/when.example.yml` ships (then connecting is a single `.env` edit), or edit both
files and recreate in one go.
To disconnect, press **Disconnect**: it revokes the grant at Google and then tells you to
clear the env var yourself. Emptying the value is enough to read as not connected.
## Operating endpoints
Web app:
- `GET /healthz` — `200 OK` once the app is up.
- `GET /metrics` — Prometheus metrics (`prom-client`).
Worker:
- `GET /healthz` on its `PORT` (default `9000`) — `{"status":"ok"}`.
## Helper CLI (`when-cli`)
You author `when.yaml` yourself — start from `config/when.example.yml`, which
documents every option (and carries a `$schema` header for editor autocomplete).
`when-cli` is an operator's toolkit for the parts a text editor can't do: it validates
the file and reaches your services over the network. It is read-only — connecting a
Google provider happens at `/admin/health`, which shows you the token to save.
Run it with `pnpm cli` on the host, or against a deployment with
`docker compose -f apps/web/docker-compose.yml run --rm when-cli <args>`.
Every command takes `-c/--config <path>` (defaults to the standard config location).
- **Scaffold a starter config** — writes a minimal valid `when.yaml` (placeholders
- `${...}` env refs); refuses to overwrite. Edit it, then validate.
```sh
pnpm cli config init
```
- **Validate** — shape + cross-references. `--check-env` also verifies every
referenced env var is set (the full boot check). Exits non-zero on failure.
```sh
pnpm cli config validate [path/to/when.yaml]
pnpm cli config validate --check-env
```
- **Providers** — report what has been observed about each provider, authenticate one, or
list the calendars it exposes (to fill `google_calendar_id` / a CalDAV `path`).
`provider list` reads the stored status and touches no network. `provider test` and
`provider calendars` run in the worker, which holds the credentials and records the
result — so a manual check shows up on `/admin/health` the same as the worker's own,
and needs a running worker.
```sh
pnpm cli provider list
pnpm cli provider test <name>
pnpm cli provider calendars <name>
```
- **Calendars** — list configured calendars, or refresh one through the worker to confirm
it is reachable. `calendar test` is a refresh a human asked for: it updates the busy
mirror and records the outcome, so it doubles as "sync this now".
```sh
pnpm cli calendar list
pnpm cli calendar test <name>
```
No command takes a refresh token. The worker reads the one `/admin/health` stored.
- **Database** — report the schema state, or apply pending migrations. Web and the worker
both migrate at boot; this is the way in when neither will start.
```sh
pnpm cli db status
pnpm cli db migrate
```
- **Email** — render and send a real test email through the worker (proves your
SMTP + branding + templates work). Requires the worker to be running; it's
triggered as a background job.
```sh
pnpm cli email test you@example.com
```
| 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. |
+30 -186
View File
@@ -1,201 +1,45 @@
# Development
Working on "When" locally. Read [`architecture.md`](architecture.md) for how the system
fits together and [`philosophy.md`](philosophy.md) for the principles changes are
measured against.
Read the [philosophy](philosophy.md). Then resist the urge to add a plugin system.
## Setup
## run it
Requires Node 26 (pinned in `mise.toml`) and pnpm. Install dependencies:
you can use Node 26 and pnpm (versions in `mise.toml`) directly. or you can use
docker. one is documented-ish. the other is not.
### docker development
With Docker, mise and fnox installed, start from `apps/web`:
```sh
pnpm install
mkdir -p config data public
cp -n ../../docs/when.example.yaml config/when.yaml
cp -n ../../docs/.env.example .env
openssl rand -base64 32
```
## Running locally
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
```
Then run:
```sh
pnpm dev
mise docker up --build
```
\`pnpm dev\` runs only the **web** app. To also deliver appointment emails and sync calendars,
run the worker against the same config in a second terminal:
```sh
CONFIG_PATH=apps/web/config/when.yaml pnpm --filter @when/worker dev
```
See [`apps/worker/README.md`](../apps/worker/README.md) for worker specifics. For a
hot-reloading stack in Docker:
```sh
pnpm dev:docker
```
## Scripts
Each package exposes the same script names; run one with `pnpm --filter @when/<pkg> <script>`,
or across the workspace with `pnpm -r <script>`. Root `build`, `check`, `lint`, and
`format` target the web app; root `test` and `test:coverage` run every workspace package.
- `build` — compile / typecheck the package.
- `check` — typecheck (`tsc --noEmit`, or `svelte-check` for web).
- `lint` — Prettier (covers Markdown too) + ESLint.
- `test` / `test:coverage` — Vitest.
Build shared packages after installing dependencies or changing package source, then run
from the root:
```sh
pnpm build:packages
pnpm test # all workspace Vitest suites, including integration tests
pnpm test:coverage # the same suites with coverage
pnpm test:e2e # Playwright (requires browsers installed)
```
For a focused feedback loop, use `pnpm --filter @when/<pkg> test <file>`.
The task combines this override with `docker-compose.dev.yml`. Web is on 5173,
Mailpit on 8025. The [sample Compose file](compose.yaml) is for deployment, not
hot reload.
## Testing
Vitest tests are co-located `*.test.ts` files next to the code. Integration tests use
`*.integration.test.ts` and run in the same suite. End-to-end tests live in
`apps/web/e2e` (Playwright).
### What earns a test its place
Prefer readable, focused regression protection over a coverage percentage. Existing
coverage thresholds are guardrails, not a target to maximize.
- **Name the behavior and observe its outcome.** Assert the saved row, returned result,
rendered text, or external request that matters. A test claiming to distinguish two
outcomes needs fixture data that makes those outcomes distinguishable.
- **Use the cheapest layer that proves the contract.** Pure calculations and validation
belong in unit tests; transactions, migrations, and durable retries need real SQLite
or workflow integration; hydration and browser navigation need Playwright.
- **Mock at boundaries, not through the behavior being tested.** Inject a `Clock`, fake
`Mailer`, or fake provider. Use the real workflow engine when the claim concerns
checkpoint persistence or retry policy.
- **Make setup explicit and isolated.** Prefer small named fixtures with scenario-specific
overrides. Do not rely on test ordering, another test's rows, developer config, or a
shared temporary filename.
- **Permit harmless refactoring.** Assert accessible behavior and meaningful copy rather
than source strings, incidental wrappers, object identity, or exact CSS dimensions.
Exact output is appropriate when it is the contract, such as an ICS field or escaping.
- **Budget runtime and duplication.** Keep each behavior at its lowest sufficient layer;
an E2E journey need not repeat a unit-test matrix. Measure slow scenarios before
deleting them: remove unnecessary waits before sacrificing integration evidence.
- **Keep failures actionable.** Test names should explain the expectation. Use bounded,
condition-based waits, retain diagnostics, and investigate retry-recovered failures.
### End-to-end
E2E covers what only a browser and a real server round trip can prove: SSR through
hydration, form actions and redirects, the auth gate as an HTTP boundary, and the client
router reconciling URL state. Availability maths, validation and templates stay in Vitest,
where they are cheaper. Browser scenarios also cover guest capabilities, host editing,
conditional forms, timezone persistence, and theme initialization.
It builds once, then runs the production adapter handler in one isolated server per
Playwright worker (four by default, overridable with `--workers`). Each server has its
own OS-assigned localhost port, copied fixture config, and app/queue databases under a
unique `e2e/fixture/data/run-*` directory. Setup copies the web source, static assets,
package manifest and build configs into a fresh project there, sharing only installed
dependencies. It uses the normal Vite/SvelteKit configuration in every build phase;
no previous build output is copied or required. Keep the explicit input list in
`e2e/setup.ts` in step with any new local imports in the build configs.
Kit and adapter build output stay inside the run directory, so another build cannot
replace a running test server's route chunks. The build and servers do not load
developer dotenv files or inherit application settings. Your `config/when.yaml`,
`data/`, build output, and dev server remain untouched; no existing server is reused.
Specs import `test` and `expect` from `./support/test.ts`. Its automatic fixture clears
appointment, busy-mirror, service-status and queue rows before each test, preserving
migration bookkeeping and live database connections. No background worker runs: tests
assert queue admission, not email delivery or calendar publication. Servers stop before
the run directory is removed.
- **Use the fixture's URL.** `page` and `request` receive the worker's `baseURL`; pass it
explicitly to additional browser contexts, cookies, and Origin headers. Never hardcode
a port.
- **Derive dates.** Pick an available slot from the page, or use `support/dates.ts` for a
future weekday and explicit scenario times. Tests need not reserve separate dates or
slots for other tests, repeats, or retries.
- **Assert the intended result.** `support/seed.ts` creates states needed by a scenario;
`support/database.ts` reads back the specific appointment and queued workflow. Use
persisted assertions when a heading alone cannot prove what was saved.
A failed test retains a screenshot and server output in `apps/web/test-results/`; the
first retry also records a Playwright trace. CI rejects retry-recovered flaky tests.
Inspect the screenshot, server log, and trace when a test fails. A later passing run
does not explain an earlier failure; investigate its cause rather than adding retries.
Repeat scheduling tests locally without retries to check isolation, for example:
```sh
pnpm --filter @when/web test:e2e booking.e2e.ts chain.e2e.ts --repeat-each=3 --retries=0
```
Running it needs `pnpm build:packages` first: `vite build` resolves `@when/*` to `dist/`,
not source. Browsers install once per Playwright version, and the version matters —
`@playwright/test` and the browsers on disk must match, or Playwright cannot find an
executable:
```sh
pnpm --filter @when/web exec playwright install chromium
```
CI runs the suite in both pipelines from a prebuilt image
(`eleith/containers` → `playwright`) that carries the browsers and Node 26. Its tag and
`@playwright/test` are a matched pair — **bump them together**. Publishing waits on the
suite in both pipelines.
## Conventions
- **Tooling:** pnpm and Node 26 only — no bun, no `npm`/`npx`. Run TypeScript CLIs under
`tsx`.
- **Exact versions:** pin dependencies exactly (no `^`/`~` ranges); add with
`pnpm add -E`. Shared tool versions live in the `pnpm-workspace.yaml` catalog
(`"catalog:"`).
- **Type safety at boundaries:** validate every external input (YAML, forms, API) against
the config JSON Schema (ajv) — the repo does not use Zod. Database access goes through
Kysely.
- **Async readability:** assign awaited results to named variables before returning
them or passing them as arguments.
- **Time:** never call `new Date()` / `Date.now()` in domain logic; inject a `Clock`.
- **Comments:** comment non-obvious rationale only. Don't restate what a name already
says.
- **Secrets:** keep secrets in `${ENV_VAR}` references, never in committed config. Never
log request bodies, session tokens, `cancel_token`s, or decrypted secrets.
- **Styling:** theme variables only, no Tailwind, copy in the markup — see the
[styling section](architecture.md#styling-and-theming-web) of the architecture doc.
### Changing the config schema
[schema.ts](file:///home/eleith/dev/when/packages/config/src/schema.ts) (TypeBox) is the source of truth. After editing it, regenerate the relaxed JSON Schema:
```sh
pnpm --filter @when/config generate:schema
```
(This runs as part of `pnpm --filter @when/config build`.) Update [config/when.example.yml](file:///home/eleith/dev/when/apps/web/config/when.example.yml) and the fixtures if you add or change a field — the web app's `example.test.ts` fails until the example documents every schema field.
## Releases
When uses [Changesets](https://github.com/changesets/changesets) with fixed app grouping (`apps/web`, `apps/cli`, `apps/worker`) so all deployable applications stay synchronized in versioning.
1. **Document changes:** when finishing a feature or fix, record a changeset:
```sh
pnpm changeset
```
2. **Bump versions & changelogs:** when ready to release:
```sh
pnpm version:apps
```
Commit the resulting version bumps and changelog updates (`git commit -m "[when] release X.Y.Z"`).
3. **Tag & deploy:**
```sh
pnpm tag:apps
git push origin main --tags
```
This triggers the automated container image builds in Woodpecker CI and GitHub Actions.
write more than zero but not so many they aren't worth their costs.
Use the cheapest test that proves the behavior.
+16 -46
View File
@@ -1,55 +1,25 @@
# Philosophy
This document explains _what_ "When" is and _why_ it is built the way it is. The
tangible "how" lives in [`architecture.md`](architecture.md); read this first, because
it explains the constraints every design decision is measured against.
Schedule appointments for one person. Do it well. Stop there.
## What "When" is
When is for the self-hoster who want a simple booking page that works well for
them and aren't going to get surprised one day with enterprise features they
are locked out of or source code that isn't open anymore (cough cough, i'm
looking at you every-other-appointment-service-ive-used)
A scheduling tool for **one** person — the individual self-hoster who wants an appointment
page on their own infrastructure. It is not, and will not become, a multi-tenant SaaS.
## keep it simple
## Principles
1. Simple: No teams, tenants, round-robin routing or plugin
system.
2. Fastish: fast enough page loads, slow work done in the worker.
3. Low Maintenance: point one, but disguised as point three.
- **One schedulable user.** There is exactly one owner. No teams, no org accounts, no
per-tenant routing. This single assumption removes most of the complexity that
scheduling products accumulate, and we spend that saved complexity budget on doing
the single-user case well.
- **Radical simplicity.** Reject multi-tenancy, team routing, round-robin assignment,
and enterprise workflow. Keep the surface area small enough to hold in your head and
to test exhaustively.
- **Configuration over UI.** The owner's setup lives in `when.yaml`, not in a
database edited through admin screens. The app reads config; it does not manage it.
This keeps state declarative, reviewable in version control, and free of migration
churn for settings.
- **No "just-in-case" code.** Build only what the single-user scope needs today. Don't
add abstract interfaces, plugin points, or routing layers for features that don't
exist yet. The adapter seams that _do_ exist (calendars) earn their keep by removing
conditionals we'd otherwise write today — not by anticipating hypothetical providers.
- **Explicit over implicit.** Avoid magic. Fail loudly with clear errors and logging.
Prefer code that reads like what it does over clever indirection.
## just a text file ...
## Why some of the bigger decisions went the way they did
all your configuration lives in `when.yaml`. Edit it, back it up, put it in
Git.
- **A worker process, separate from the web app.** Calendar I/O and email are slow and
can fail; running them inside a request would make appointment scheduling feel fragile and slow.
Instead the web app does the minimum on the request path (write the appointment, enqueue
a job) and a background worker does the rest — sending emails, pushing to calendars,
refreshing busy times — with durable retries. The request stays fast and the
side-effects become observable and replayable.
- **State in `when.yaml` + SQLite, nothing else.** Settings are config; appointments are
rows. There is no third place state can hide. SQLite (via Node's built-in
`node:sqlite`) means zero external services to run and a single file to back up.
- **One JSON Schema as the source of truth for config.** Rather than hand-writing
validators (e.g. Zod) _and_ types _and_ editor hints separately, we keep one
canonical JSON Schema and generate the rest: strict TypeScript types, and a relaxed
copy for editor autocomplete. Validation, types, and editor tooling can't drift
because they share one origin. Required-but-defaulted fields stay strict in code (ajv
fills the default at load) while the editor copy relaxes them so it doesn't nag.
- **A single brand hue.** Branding is one `primary_color`; the rest of the palette is a
tonal scale derived from it. One knob to set, a coherent theme guaranteed, and no
surface for a self-hoster to build an inconsistent look.
## ... and two sqlite files
When a change tempts you to add a tenant boundary, a settings table, a second brand
color, or an abstraction for a provider you don't have — that's the signal to stop and
re-read this page.
Appointments live in one SQLite. Background jobs live in another. They didn't
look as good in yaml.
@@ -1,10 +1,8 @@
# yaml-language-server: $schema=../../../packages/config/src/config.schema.json
#
# Example When configuration. Copy this to `when.yaml` in the same directory and
# edit. The uncommented blocks are a complete, valid starter config; commented
# lines document every remaining field and its default, so you can uncomment
# what you need. Secrets are `${ENV_VAR}` references — set those in your
# environment (docker loads them from `.env`).
# 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.
# Shape of this file. Defaults to 1 and only changes if the config layout does.
# version: 1
@@ -124,12 +122,13 @@ meetings:
# additional_busy_calendars: ['personal'] # further calendars to check; booking_calendar is always checked
# location: 'Suite 200, 123 Main St' # a static address, phone, or link
# video_chat:
# provider: 'google-service' # generate a video link per booking
# provider: 'nextcloud-service' # configure the provider above first
# attach:
# auto: true # auto: true (default) or auto: false (admin generates on demand)
# when: # or conditionally attach based on guest form answers
# - field: 'topic'
# equals: 'Video Meeting'
# auto: true # or false for host-only generation; use auto OR when, not both
# # when: # instead of auto, match the form below
# # - field: 'topic'
# # equals: 'Support'
# Google Meet requires a Google booking calendar.
# note: 'Please add an agenda when you book.'
# notice_minutes: 120 # minimum lead time (default 120)
# booking_window_days: 60 # how far ahead bookings open (default 60)
@@ -138,12 +137,20 @@ meetings:
# daily_booking_limit: null # null = unlimited
# show_slots: false # true draws the slots as buttons; false is a bare drag timeline
# start_times_every_minutes: 15 # slot step (default 30; independent of the lengths offered)
# form_fields: # extra questions asked at booking
# form_fields: # replaces the default form; include a required guest_name
# - name: 'name'
# type: 'guest_name'
# label: 'Your name'
# required: true
# - name: 'email'
# type: 'guest_email'
# label: 'Your email'
# required: true
# - name: 'topic'
# type: 'text' # guest_name | guest_email | event_location | text | number | phone | paragraph | choice
# type: 'choice' # guest_name | guest_email | event_location | text | number | phone | paragraph | choice
# label: 'What would you like to discuss?'
# required: false
# choices: ['Sales', 'Support'] # only for type: choice
# choices: ['Sales', 'Support'] # only for choice or event_location
# - name: 'phone'
# type: 'phone'
# label: 'Your phone number'
+2 -86
View File
@@ -1,88 +1,4 @@
# @when/calendar
External-calendar I/O for the `when` workspace: provider adapters (Google,
CalDAV), provider-expanded busy-time fetching, appointment push/delete, and local
helpers like ICS generation.
The **provider-reaching logic** — the adapters and every network call to an
external calendar (busy-time fetch, appointment push/update/delete) — is meant to
run **only in the worker**. The worker is the only part of `when` that talks to
external calendars: it fetches busy times into a local mirror and pushes
appointments to the organizer's calendar. Web reads the already-expanded mirror
from `@when/db` and makes no provider call.
Web does import this package, but only the local, network-free pieces — `buildIcs`
(the `.ics` download endpoint) and `setLogger` (logging wiring). Tree-shaking
keeps the adapter/provider code out of the web bundle because web never
references it.
Calendar publishing writes appointment content only. Selected video URIs appear in
appointment descriptions (including non-HTTP URIs); publishing does not create,
copy, clear, or adopt conferences. Google updates use PATCH with an encoded event
ID; creates use POST. Neither sends `conferenceData` or applies video attach policy.
## Busy-time contract
Both adapters return concrete occurrences with UTC start/end instants and a provider
identity for own-event exclusion: CalDAV UID or Google event ID.
Google uses `singleEvents=true`; CalDAV requests `CALDAV:expand` for the same bounded
window as its query filter ([RFC 4791 §9.6.5](https://www.rfc-editor.org/rfc/rfc4791.html#section-9.6.5)).
There is no local recurrence engine or fallback when a server ignores expansion.
CalDAV imports reject failed resources or calendar-data properties, malformed calendar
data, remaining recurrence rules/exclusions, and unresolved timezone references. The
expanded-data reader accepts VCALENDAR containing VEVENTs with optional nested VALARMs;
unrequested component types are rejected rather than interpreted as event properties.
The worker retains the previous busy mirror and records a refresh failure. A successful
import with no remaining busy intervals clears it. Cancelled and transparent occurrences are discarded. Multiple occurrences sharing a
UID remain distinct, while the common path excludes our own published UIDs and keeps
intervals overlapping the requested window without clipping their bounds.
Date-only and floating date-times retain the existing UTC interpretation. The REPORT
supplies a UTC `CALDAV:timezone` ([RFC 4791 §9.8](https://www.rfc-editor.org/rfc/rfc4791.html#section-9.8))
so server selection uses that same policy instead of the calendar's default timezone.
Explicit timezone-referenced date-times are still resolved by the server and returned
in UTC. Durations on concrete occurrences use elapsed UTC time, including 24-hour days;
no server-local Date arithmetic is used. Outgoing ICS generation and publishing are unchanged.
### CalDAV XML parsing
REPORT imports and PROPFIND discovery share one `@rgrove/parse-xml` boundary for
XML 1.0 syntax, entity decoding, and namespace-aware field selection. REPORT checks
resource/property statuses and payload cardinality before importing busy times.
Discovery reads principal, home-set, and calendar properties from the same tree;
hrefs and display names must be text-only and are XML-decoded once, without adding
a percent-decoding step. Missing discovery properties retain the base-URL and
unnamed-calendar fallbacks, but malformed XML is rejected rather than returning
a partial calendar list.
Comments and processing instructions are ignored; namespace conformance inside
ignored extension content is not validated. Literal CRLF/CR becomes LF;
numeric carriage-return references remain intact. DTDs are rejected, and parser
errors are sanitized so calendar contents do not appear in errors.
REPORT source text remains limited to 16 × 1024 × 1024 UTF-16 code units, not bytes.
The reader checks this while decoding response chunks and cancels an oversized
response instead of importing a prefix. Direct parsing checks the same ceiling
before newline normalization. There is no application XML nesting ceiling; the
library is recursive and may reject deeply nested input. Read or parsing failures
leave the previous mirror intact.
The text ceiling is not a CalDAV requirement, measured capacity target, or total
process-memory limit. Received chunks, parsing, and interval construction still
require additional memory.
## Google event I/O
`getGoogleEvent` reads a stored event. `patchGoogleEvent` sends supplied fields and
query parameters to that event. Both reuse Google authentication and return
unvalidated response data. PATCH 204 returns `undefined`, distinct from JSON `null`.
The Google adapter in `@when/video-chat` accepts event operations backed by these
functions. Conference payloads and interpretation belong to that adapter, not the
calendar transport. Neither package owns worker retries or appointment writes.
## Scripts
- `pnpm build` — typecheck (`tsc -p tsconfig.json`) then emit `dist/`.
- `pnpm test` / `pnpm test:coverage` / `pnpm lint`.
Google and CalDAV adapters, busy-time imports and ICS helpers. Provider calls
belong in the worker; web uses only the local helpers.
+2 -23
View File
@@ -1,25 +1,4 @@
# @when/config
Shared configuration loading and schema for the `when` workspace.
Owns the canonical config schema [schema.ts](file:///home/eleith/dev/when/packages/config/src/schema.ts) defined using TypeBox. The loader parses, interpolates `${ENV}` references, fills defaults (including a few that depend on siblings, e.g. a slug derived from a meeting name), validates with TypeBox, and performs cross-reference validation.
The source schema keeps every defaulted field strict (non-optional) so TypeScript types are strong. The generated JSON Schema ([config.schema.json](file:///home/eleith/dev/when/packages/config/src/config.schema.json)) is programmatically relaxed (omitted-but-defaulted fields are removed from `required` rules) so an editor pointed at it via `# yaml-language-server: $schema=...` doesn't flag them as missing. Point a `when.yaml` to this generated schema.
Consumed by `@when/web` (producer) and `@when/worker`. App-specific boot glue
(SvelteKit `$env`, metrics) stays in the consuming app.
## API
- `loadConfigFile(path)` — read + parse + validate a `when.yaml`.
- `validateConfig(raw)` — validate an already-parsed object.
- `interpolate(node, env?)` — expand `${ENV_VAR}` references.
- `ConfigError`, `MissingEnvVarsError` — typed failures.
- `schema` / `externalSchema` — the generated relaxed JSON Schema.
- Types: `WhenConfiguration`, `Meeting`, and all other statically generated TypeBox types.
## Scripts
- `pnpm generate:schema` — regenerate `src/config.schema.json` from `src/schema.ts`.
- `pnpm build` — run `generate:schema`, compile TS files via `tsc`, and build assets.
- `pnpm test` / `pnpm lint`.
YAML loading, defaults and validation from the [TypeBox schema](src/schema.ts).
See [configuration](../../docs/config.md) for fields and examples.
+2 -62
View File
@@ -1,64 +1,4 @@
# @when/db
Shared SQLite data layer for the `when` workspace.
Wraps Node's built-in `node:sqlite` behind a small Kysely dialect (no native
addons), and owns the schema types, migrations, and migration runner. Consumed
by `@when/web` and `@when/worker`, which both open the same `when.sqlite`.
## API
- `openDb(path)` — open (and, for file paths, create the parent dir of) a
`node:sqlite` database as a typed `Kysely<Database>`.
- `runMigrations(db)` — apply pending migrations; returns the applied names (the
caller logs — the package stays logger-agnostic).
- `migrations` — the ordered migration set.
- `withWriteTransaction(db, write)` — reserve SQLite write access before a short
read-modify-write operation. The callback gets a leased Kysely connection, not a
`Transaction`. Use only that connection and await all queries before returning;
do not nest transactions, close or retain it. Returning commits; throwing rolls
back. Lock acquisition can still time out.
- `findAppointment(db, id)` / `getAppointment(db, id)` — read an appointment by ID.
`findAppointment` returns `undefined` when missing; `getAppointment` requires a row
and rejects with Kysely's `NoResultError` when missing.
- `recordVideoChatGenerationRequest(db, appointmentId)` — store standing `generate`
intent and return its calendar revision. Repeating it preserves the entire row;
changing it increments only the calendar revision and update time, not ICS or history.
Call inside the write transaction that checked eligibility.
- `lastCalendarRefreshAt(status)` — proven mirror-refresh timestamp, or `null` when
the latest observation cannot establish refresh freshness. Shared by worker and web.
- `recordCalendarEventReference(db, appointmentId, reference)` — record which
calendar event belongs to an appointment, filling only compatible missing IDs.
Recording a new reference invalidates sync evidence; repeating it does nothing.
- `markSynced(db, appointmentId, revision, reference)` — record sync evidence only
while the revision and both reference IDs still match on a non-purged row. The
required reference permits null IDs, compared null-safely.
- `recordCalendarEventDeletion(db, appointmentId, revision, reference)` — atomically
clear both IDs and record sync evidence only for a matching revision and complete
reference on a non-purged row. Records a receipt after provider deletion; does not
contact the provider. Replaying after the IDs are cleared is a no-op.
- `listActiveAppointmentsForCalendarEvent(db, reference)` — return the pending and
confirmed appointments referencing that event. This reads local rows, not the provider.
- `parseNotificationStatus(raw)` — parse the `appointments.notification_status`
JSON column.
- Types: `Database`, `Appointment`, `NewAppointment`, `AppointmentUpdate`,
`AppointmentStatus`, `VideoChatRequest`, `NotificationStatus`, `NotificationOutcome`, …
A `CalendarEventReference` contains `{ calendarId, eventId }`: the configured
calendar and the provider's event ID. The query returns the appointments themselves,
so callers can distinguish the current appointment from others using the event.
`video_chat_request` is a standing instruction, not job progress:
- `NULL` follows configured attachment rules.
- `generate` asks for a generated link when one is missing, regardless of attachment rules.
- `suppress` prevents generation without removing a selected URL.
The selected URL remains in `video_chat`. Existing and new appointments default to
`NULL`. Migration does not interpret activity history or change existing URLs, ICS
sequences or calendar revisions; historical Remove choices are not carried forward.
## Scripts
- `pnpm build` — typecheck (`tsc --noEmit`).
- `pnpm test` / `pnpm test:coverage` / `pnpm lint`.
The app's SQLite schema, queries and migrations, using `node:sqlite` and Kysely.
The workflow queue lives in [@when/jobs](../jobs/README.md), not here.
+5 -96
View File
@@ -1,99 +1,8 @@
# @when/jobs
Shared workflow contracts for the `when` workspace, built on
[OpenWorkflow](https://openworkflow.dev) `0.11.0` over `node:sqlite`.
Shared workflow contracts and OpenWorkflow queue access for web, CLI and worker.
Producers enqueue work; the worker implements it.
The producer (`@when/web`, or `@when/cli` for service probes) enqueues work from a
shared spec; `@when/worker` registers the implementation. Web never imports worker
implementations. Initializing a producer client does not start a worker or register
recurring schedules.
## API
- `reconcileAppointment` — coordinate appointment calendar/video updates and email.
- `purgeAppointment` — clean up purged appointment chains.
- `syncCalendars` — `{}` → `requested`, after the actual calendar repair scan finishes.
- `refreshCalendars` — `{}` → `refreshed`, after refresh and conflict checks.
- `expirePendingAppointments` — `{}` → `expired`, after idempotent expiry SQL.
Completed maintenance passes can include recorded provider failures.
- `testEmail`, `testProvider`, `listProviderCalendars`, `testCalendar`,
`generateVideoChat` — on-demand worker operations.
- `initOpenWorkflow({ dbPath, logger? })` — open and migrate the queue database and
initialize the process-wide client. The first call wins, including its logger.
- `getOpenWorkflow()` — return that client; throws before initialization or after
closing.
- `closeOpenWorkflow()` — idempotently close the queue connection, **after stopping
its worker**. Closing an initialized client is terminal for that process. Calling
this before initialization is a no-op.
- `getWorkflowRun(db, id)`, `getStepAttempts(db, id)` — read queue history through a
caller-owned `DatabaseSync` connection.
Specs and their input/output types live in `src/specs.ts`. The `./specs` entrypoint
exports contracts without the process-wide client helpers.
Queue migrations run on connection, including producer connections. Do not start a
new producer against the old deployment during cutover. Saved results do not make
changed application graphs safe to replay. Follow the
[grouped rollout runbook](../../docs/deployment.md#grouped-workflow-rollout): drain all
unfinished old work before deployment, then stop and back up both SQLite databases
consistently. Never automatically rerun terminal history. Reusing an enqueue key
can return an existing terminal run within the engine's 24-hour dedup window.
Prepared envelopes contain personal data and guest capability links; queue files and
backups are sensitive. Remote effects and logs can duplicate after a crash before
checkpointing, so neither durable steps nor enqueue keys provide exactly-once effects.
## Schedule management
Only native worker `start()` persists the declarations from registered implementations.
Startup awaits it, then enqueues refresh, expiry and sync independently with `{}` and
no run options. Their UTC schedules are `*/5 * * * *`, `0 * * * *`, and `*/10 * * * *`,
with IDs `when:refresh-calendars`, `when:expire-pending-appointments`, and
`when:sync-calendars`. Upserts preserve pause; startup runs do not depend on cron's pause
state. Cron is wall-clock aligned and catches up once per overdue schedule, not per
missed tick. No application timers or coalescers supplement it.
These are the installed `0.11.0` public APIs, not `when-cli` commands. Given an initialized
client connected to the intended queue, list every page:
```ts
const client = getOpenWorkflow();
let after: string | undefined;
do {
const page = await client.schedules.list({ limit: 100, after });
console.table(
page.data.map(({ id, cron, timezone, nextRunAt }) => ({
id,
cron,
timezone,
nextRunAt
}))
);
after = page.pagination.next ?? undefined;
} while (after);
```
Manage one schedule explicitly (the following are separate operator actions):
```ts
await client.schedules.pause('when:sync-calendars');
await client.schedules.resume('when:sync-calendars');
await client.schedules.delete('when:sync-calendars');
```
`nextRunAt === null` means paused; resume computes the next future occurrence. Pause
and delete do not cancel already queued runs or suppress independent startup runs.
Removing a declaration alone leaves its persisted schedule active. To retire a target,
stop the worker, delete its schedule through the public API, and remove the declaration
before restarting; otherwise native startup recreates it. Schedule-management connections
also migrate queues, so use the matching deployed version, not a new client for old-queue
read-only inspection.
## Scripts
- `build` — typecheck and compile to `dist/`.
- `check` — typecheck without emitting.
- `test` / `test:coverage` — unit and real-engine integration tests.
- `lint` — Prettier and ESLint.
Tests use temporary queues, never a personal deployment database.
Opening a queue client runs migrations. Follow the
[update guidance](../../docs/deployment.md#updates) before
connecting a new version to an old queue.
+2 -32
View File
@@ -1,34 +1,4 @@
# @when/video-chat
Callers can obtain a `VideoChatAdapter` from the configured service, supplying
an event binding for Google:
```ts
const adapter = getVideoChatAdapter(service, bookingEvent);
const result = await adapter.request({ name, requestId });
```
The shared contract has three operations:
- `request({ name, requestId })` requests a link. The name labels a standalone room;
the request ID supports providers with creation idempotency.
- `read(knownUrl)` observes the provider resource. The URL may be `null` when no
standalone room is known yet.
- `removeLink(knownUrl)` removes the provider's link or room, not appointment data.
The factory selects from `service.type`. Nextcloud uses its server configuration;
Google requires the already-booked event supplied as the second argument. The
Google implementation owns that event dependency, not the shared interface.
Google reads and updates conferencing on that event; removal does not delete the
booking event. Its event operations are backed by `@when/calendar`, without a
reverse package import. The event object stays in memory, not in checkpoints.
Nextcloud creates, reads and deletes standalone rooms. A missing URL means no known
room; a failed lookup of a known URL is not evidence that it is safe to replace it.
`nextcloudRoomToken` identifies canonical room aliases within one installation.
A successful observation may be ready, pending, absent, failed or ambiguous.
`ok: false` means the I/O operation failed, not that a conference is absent.
The worker owns current-choice and reference checks, native checkpoints, retries,
appointment writes and notification handoff. Adapters do no polling or persistence.
Google Meet and Nextcloud Talk adapters. The worker owns retries and persistence;
these adapters make provider calls, not scheduling decisions.