diff --git a/README.md b/README.md index af4c4039..1e1a94d9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/cli/src/commands/config/init.ts b/apps/cli/src/commands/config/init.ts index a5f10d9b..1fff514b 100644 --- a/apps/cli/src/commands/config/init.ts +++ b/apps/cli/src/commands/config/init.ts @@ -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' + ); } }); diff --git a/apps/web/.env.example b/apps/web/.env.example deleted file mode 100644 index 35861ec8..00000000 --- a/apps/web/.env.example +++ /dev/null @@ -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; `` is the provider's key -# upper-cased, with `-` as `_`. - -WHEN_ADMIN_PASSWORD= -WHEN_SMTP_PASSWORD= -# WHEN_OIDC_CLIENT_SECRET= -# WHEN_PROVIDER__PASSWORD= -# WHEN_PROVIDER__CLIENT_SECRET= -# WHEN_PROVIDER__REFRESH_TOKEN= -# WHEN_METRICS_TOKEN= diff --git a/apps/web/src/lib/server/config/boot.ts b/apps/web/src/lib/server/config/boot.ts index 50033655..ae552c18 100644 --- a/apps/web/src/lib/server/config/boot.ts +++ b/apps/web/src/lib/server/config/boot.ts @@ -18,7 +18,7 @@ export async function bootConfig(path: string = resolveConfigPath()): Promise { 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(); }); diff --git a/apps/worker/README.md b/apps/worker/README.md index 35af28af..95899043 100644 --- a/apps/worker/README.md +++ b/apps/worker/README.md @@ -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, - `/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). diff --git a/docs/.env.example b/docs/.env.example new file mode 100644 index 00000000..4ec0d785 --- /dev/null +++ b/docs/.env.example @@ -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 with the provider key, uppercase, hyphens as _. +# WHEN_OIDC_CLIENT_SECRET= +# WHEN_PROVIDER__PASSWORD= +# WHEN_PROVIDER__CLIENT_SECRET= +# WHEN_PROVIDER__REFRESH_TOKEN= +# WHEN_METRICS_TOKEN= diff --git a/docs/architecture.md b/docs/architecture.md index 16575601..1eeaf6ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 `