ci/woodpecker/pr/calendar Pipeline was successful
ci/woodpecker/pr/cli Pipeline was successful
ci/woodpecker/pr/config Pipeline was successful
ci/woodpecker/pr/db Pipeline was successful
ci/woodpecker/pr/jobs Pipeline was successful
ci/woodpecker/pr/video-chat Pipeline was successful
ci/woodpecker/pr/web Pipeline was successful
ci/woodpecker/pr/worker Pipeline was successful
ci/woodpecker/push/calendar Pipeline was successful
ci/woodpecker/push/cli Pipeline was successful
ci/woodpecker/push/config Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/jobs Pipeline was successful
ci/woodpecker/push/video-chat Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/worker Pipeline was successful
432 lines
23 KiB
Markdown
432 lines
23 KiB
Markdown
# Configuration
|
||
|
||
One `when.yaml` for the web app and worker. Auth, availability, calendars, email,
|
||
and what guests can book. No settings scavenger hunt.
|
||
|
||
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=http://localhost:3000/schema.json
|
||
```
|
||
|
||
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).
|
||
|
||
## File shape
|
||
|
||
| 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. |
|
||
|
||
`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.
|
||
|
||
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:
|
||
cloud:
|
||
type: caldav
|
||
url: 'https://cloud.example.com/remote.php/dav/'
|
||
username: jane
|
||
password: '${WHEN_CALDAV_PASSWORD}'
|
||
calendars:
|
||
work:
|
||
href: 'calendars/jane/work/'
|
||
```
|
||
|
||
Unknown options are rejected. References to schedules, calendars, providers, and
|
||
form fields are checked at load time.
|
||
|
||
## Environment variables
|
||
|
||
Any string value can contain `${ENV_VAR}`. Values are substituted before runtime
|
||
validation, including in defaults. This is optional: literal values in private
|
||
YAML work without an environment file. If you prefer interpolation, the sample
|
||
Compose file passes an optional `.env` to web, worker and CLI. Keep files containing
|
||
secrets private and out of Git.
|
||
|
||
- `${VAR}` fails loading if the variable is unset.
|
||
- `${VAR:-fallback}` uses the fallback if the variable is unset, not if it is empty.
|
||
- `$$` 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: '' # required: set your own password before starting
|
||
```
|
||
|
||
Or replace it with OIDC:
|
||
|
||
```yaml
|
||
auth:
|
||
oidc:
|
||
issuer: 'https://auth.example.com'
|
||
client_id: when
|
||
client_secret: '' # required: your provider's client secret
|
||
# name: 'Single Sign-On' # default display name
|
||
```
|
||
|
||
`credentials.username` is required; `password` must be non-empty. Set it directly
|
||
in YAML or use interpolation. Omitting it still uses `${WHEN_ADMIN_PASSWORD}` for
|
||
existing environment-driven configs. OIDC requires `issuer`, `client_id`, and
|
||
`client_secret`; `name` defaults to `Single Sign-On`.
|
||
|
||
Optional `auth.secret` is a session signing key of at least 32 characters after
|
||
interpolation. With no YAML secret, web uses `AUTH_SECRET` if set; otherwise it
|
||
generates a secure random key at startup. Generated keys sign everyone out on web
|
||
restart. Set a stable YAML secret (or keep `AUTH_SECRET`) to preserve sessions.
|
||
Generate one once with `openssl rand -base64 32`. Ordinary config reloads keep the
|
||
current key. Empty or short configured keys are errors, not requests to generate
|
||
one. Structural CLI validation leaves interpolation references unresolved;
|
||
`config validate --check-env` checks the resolved YAML key's length.
|
||
|
||
Both strategies work in production. Choose based on who can reach sign-in:
|
||
|
||
- **Credentials:** suitable on a LAN, VPN, or behind an authenticating proxy.
|
||
Native sign-in throttling uses the trusted `X-Real-IP` header. Keep the backend
|
||
private and have the proxy overwrite that header; this is not durable lockout.
|
||
- **OIDC:** recommended for an internet-facing deployment. Throttling, lockout,
|
||
password policy, and MFA belong to your identity provider.
|
||
|
||
If you expose credentials sign-in publicly, also 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.
|
||
|
||
For an `avatar_path` of `/public/photo.webp`, `/avatar/public/photo.webp` serves
|
||
the original and `/avatar/public/photo.webp?thumbnail=true` serves a thumbnail.
|
||
The path is only checked against the configured avatar: mismatches return 404,
|
||
and the app always loads the source selected by configuration, never by the
|
||
request path. Changing the configured path changes the browser's cache key
|
||
without embedding a path in a query parameter.
|
||
|
||
Web pages request thumbnails automatically, resizing raster avatars to at most
|
||
216 × 216 pixels (enough for the largest 72-pixel avatar at 3× density).
|
||
`thumbnail=false` also serves the full image; other values are rejected. Keep
|
||
setting only `avatar_path`; no separate thumbnail or image tool is needed.
|
||
Generated share cards continue to load the original image.
|
||
|
||
Thumbnail responses preserve SVGs, small images and animations. It uses WebP
|
||
only when the result is smaller, and falls back to the original on conversion
|
||
failure. Sources above 8 MiB are served directly instead of processed. The app
|
||
caches the current derivative in memory and revalidates its source when the route
|
||
is requested. Concurrent requests share source loading and resizing. Browser
|
||
caching lasts up to one hour; changing `avatar_path` also
|
||
changes the web thumbnail's cache key. The source file is never overwritten.
|
||
|
||
**Known limitation:** Takumi currently ignores EXIF orientation metadata. A photo
|
||
that relies on that metadata may appear rotated in the generated thumbnail. This
|
||
also occurs with Takumi 2.19.0; it is not corrected by this optimization.
|
||
|
||
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; literal or optional interpolation. |
|
||
| `from` | `noreply@<url.app domain>` | Sender address your server is allowed to use. |
|
||
|
||
`from` is also the organizer on guest-facing calendar invites, keeping the host's
|
||
own address out of those fields. The sender display name is always `user.name`.
|
||
|
||
## `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. Save it as the provider's YAML `refresh_token`, or use optional
|
||
environment interpolation. Environment changes require
|
||
[recreating the containers](deployment.md#environment-variables).
|
||
|
||
## `schedules`
|
||
|
||
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:
|
||
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'
|
||
```
|
||
|
||
- 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 key is the booking URL slug: `chat` gives `/schedule/chat`.
|
||
|
||
```yaml
|
||
meetings:
|
||
chat:
|
||
title: 'A quick chat'
|
||
schedule: standard
|
||
booking_calendar: work
|
||
duration_minutes: 30
|
||
additional_duration_minutes: [15, 60]
|
||
```
|
||
|
||
### Booking options
|
||
|
||
| 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. |
|
||
| `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. |
|
||
|
||
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`
|
||
|
||
| Field | Default | Contents |
|
||
| ------- | ---------------------------- | -------------------------------------------------- |
|
||
| `app` | `./data/when.sqlite` | Appointments, mirrored busy times, service status. |
|
||
| `queue` | `./data/openworkflow.sqlite` | Background job queue. |
|
||
|
||
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`
|
||
|
||
| 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. |
|
||
|
||
Set `url.app` in YAML to your public HTTPS URL for authentication and generated
|
||
links. `ORIGIN` remains an optional default when that YAML field is omitted;
|
||
an explicit YAML value takes precedence.
|
||
SvelteKit 3's native adapter ignores `ORIGIN` and derives its request origin from
|
||
trusted proxy headers and Host instead. These must match the configured public
|
||
URL; see the [proxy requirements](deployment.md#reverse-proxy).
|
||
|
||
## `prometheus`
|
||
|
||
| Field | Default | Meaning |
|
||
| --------- | ------------------------- | -------------------------------------------------- |
|
||
| `enabled` | `false` | Expose `/metrics`; instrumentation remains active. |
|
||
| `token` | `${WHEN_METRICS_TOKEN:-}` | Bearer token required for scraping. |
|
||
|
||
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) and the
|
||
[metrics reference](metrics.md) for collection health, action counts and job semantics.
|