# calrs — Claude Code Context
## Project overview
`calrs` is an open-source scheduling platform written in Rust. It is a self-hostable alternative to Cal.com, starting as a CLI tool before adding a web interface. The project is named **calrs** (potential domain: `cal.rs`).
**Core concept:** Connect your CalDAV calendar(s), define bookable meeting types with availability rules, and eventually share a booking link. No Node.js, no PostgreSQL, no SaaS subscription.
**License:** AGPL-3.0
---
## Tech stack
| Concern | Choice | Notes |
|---|---|---|
| Language | Rust (2021 edition) | Targeting stable |
| CLI | `clap` v4 (derive API) | Subcommand tree pattern |
| Async runtime | `tokio` (full features) | Used throughout |
| Database | SQLite via `sqlx` 0.7 | WAL mode, foreign keys enabled, migrations inlined |
| HTTP client | `reqwest` 0.12 (rustls, no openssl; `http2` and `charset` enabled by hand since default features are off) | CalDAV PROPFIND/REPORT and EWS SOAP requests |
| Calendar providers | trait `CalendarProvider` (`src/providers/`) | Pluggable back-ends: CalDAV, EWS (Exchange 2019). Sync/source code dispatches via the trait. |
| Async traits | `async-trait` 0.1 | Object-safe `dyn CalendarProvider`. |
| XML parsing | `quick-xml` 0.39 | CalDAV (WebDAV) and EWS (SOAP) responses, read into a namespace-aware tree by `src/xml.rs` |
| iCal | `icalendar` crate | Parsing/generating VEVENT data |
| Time | `chrono` + `chrono-tz` | Timezone handling is a known complexity area |
| IDs | `uuid` v1 | UUID v4 for all primary keys |
| Terminal output | `colored` + `tabled` | Colored text and ASCII tables in CLI output |
| Web server | `axum` 0.8 | HTTP booking page, served from CLI |
| Templates | `minijinja` 2 | Jinja2-compatible, loaded from `templates/` dir |
| OpenAPI | `utoipa` 6 | REST API document generated from the handlers (`src/web/api_doc.rs`) |
| Encryption | `aes-gcm` | AES-256-GCM encryption for stored credentials |
| Auth | `argon2` + `password-hash` | Argon2 password hashing for local accounts |
| Auth (OIDC) | `openidconnect` 4.x | OpenID Connect SSO (Keycloak, etc.) with PKCE |
| Sessions | `axum-extra` (cookies) | Server-side sessions in SQLite, HttpOnly cookies |
| Email | `lettre` 0.11 | SMTP with STARTTLS, async tokio transport |
| Logging | `tracing` + `tracing-subscriber` | Structured logging with env-filter |
| HTTP tracing | `tower-http` 0.6 | TraceLayer for request-level observability |
| Error handling | `anyhow` (app-level) + `thiserror` (lib-level) | Standard Rust pattern |
| Config/paths | `directories` crate | XDG-compliant data dir: `$XDG_DATA_HOME/calrs` |
---
## Project structure
**Read `ARCHITECTURE.md` first.** It maps every module under `src/` (including each file of `src/web/` and `src/email/` and their test directories) to what it owns, explains the cross-cutting concepts with the file that owns each, and lists where to add a route, an email, a string, a migration or a provider. It replaces the file tree that used to live here.
The two large modules are split by feature area, so open the file for the area you are changing rather than searching one big file:
- `src/web/mod.rs` keeps `AppState`, `create_router` (every route), middleware and shared helpers; handlers live in `public_pages.rs`, `booking.rs`, `booking_actions.rs`, `availability.rs`, `dashboard.rs`, `event_types.rs`, `invites.rs`, `teams.rs`, `sources.rs`, `admin.rs`, `ews_admin.rs`, and helpers in `hosts.rs`, `calendar_push.rs`, `pending.rs`, `notify.rs`, `branding.rs`, `format.rs`. Submodules `use super::*` and `mod.rs` glob-imports them, so an item moved between files keeps its call sites. Tests are in `src/web/tests/.rs` with shared fixtures in `src/web/tests/mod.rs`.
- `src/email/mod.rs` re-exports `transport.rs`, `render.rs`, `guest.rs` and `host.rs` with `pub use`, so callers keep writing `crate::email::name`. Tests are in `src/email/tests/`.
---
## Database schema (SQLite)
Migrations are tracked via `_migrations` table and run incrementally at startup via `db::migrate()`. Files are `migrations/NNN_description.sql` up to 081, then `YYYYMMDDHHMMSS_description.sql` (#333); see "When adding a new migration" below.
Key tables:
- **`users`** — multi-user: email, name, password_hash (argon2), role (admin/user), auth_provider (local/oidc), oidc_subject, username (unique), enabled flag, title, bio, avatar_path, language (saved UI language, NULL = browser), public_page_language ('visitor' or 'host', language of the user's /u/ pages), based_in (free text) and spoken_languages (BCP-47 codes joined with commas, from `landing::SPOKEN_LANGUAGES`) shown under the name on /u/, email_trusted (calrs may open the Exchange mailbox the address names, see "Global Exchange impersonation")
- **`sessions`** — server-side sessions: token (PK), user_id, expires_at (30-day TTL)
- **`auth_config`** — singleton: registration_enabled, allowed_email_domains, OIDC settings (issuer, client_id, client_secret, auto_register), `guest_email_footer` (instance default footer for guest emails, migration `20261107192406_guest_email_footer`), `guest_footer_allow_all_users` (every user may write footers; admins always may)
- **`accounts`** — scheduling accounts linked to users via `user_id`. A new user (registration, OIDC auto-register, `calrs user create`) always gets a new account from `auth::create_user_account`, in the transaction that inserts the user; an existing account is never handed over. `accounts.email` (unique) is informative only (read by the orphan migration and `calrs config dump`); an OIDC login that changes `users.email` updates it when free, and a stale copy blocking a new user's address is rewritten to its owner's current address or to a free `account-[-n]@invalid` placeholder (`unused_placeholder`). `db::migrate_orphaned_accounts` links an account with no user to the user holding its address only when that user has no account, and creates no user when an address matches one only up to case or spaces; otherwise it logs a warning and leaves it
- **`caldav_sources`** — CalDAV server connections (URL, credentials, sync state, `write_calendar_href`). `enabled` flag, `ON DELETE CASCADE`
- **`calendars`**: calendar collections discovered under a source; `is_busy=1` means its events block availability by default (an event type that selects the calendar is blocked by it either way); `sync_enabled=0` (migration 079, #324, set per source at `/dashboard/sources/{id}/calendars` or `calrs source calendars`) means never fetched, no cached events, never blocks, even when an event type selects it. Busy queries check both flags; `commands::sync::set_calendar_sync_enabled` is the one way to flip it, it bumps `sync_generation`, and a sync only stores its ctag/sync token when the generation it started with is unchanged. Turning a calendar on also nulls its source's `last_synced` so the next slot page fetches it. The orphan sweep skips a write calendar that is not synced. `read_only=1` (migration `20261108100217_calendar_read_only`, Graph `canEdit: false`) keeps it out of the write-back choices, unless already selected: then it stays selected and the sources page flags it. A write destination must be a writable calendar row of the source. A Graph calendar owned by someone else starts with `is_busy=0`; nothing changes `is_busy` afterwards. Microsoft Graph sync deletes a calendar Graph no longer lists, with its events, except the source's write calendar; EWS sync has its own path, `sync_ews_source`, which removes a folder only after two listings miss it (#362). The Graph-only snapshot rules (unreadable item fails the sync, empty snapshot purges the window, unlisted calendars go) are gated by `strict_snapshots()` in `commands/sync.rs`; EWS keeps its own path. A suspended user's Microsoft sources are skipped by every sync query (`NOT_SUSPENDED_MICROSOFT`) and their tokens are never refreshed. On a Microsoft source `last_full_sync` is stamped only by a sync in which the write calendar and every calendar that counts for availability returned a snapshot (unreadable items allowed), so NULL means not read yet: `microsoft_gap_intervals()` then makes the host busy (web busy times, `calrs event-type slots` and `calrs booking create` via `host_microsoft_gaps`, per team mode), as it does past `last_full_sync` + 729 days; the sources page, troubleshoot and approvals (`approval_hosts_awaiting_first_sync`) say so. Snapshot writes and the source's freshness stamps are conditional on the calendars' `sync_generation` in the same statement. A uid vanishing from a provider calendar reaches `cancel_orphaned_booking` only when the booking was written to that very calendar (`booking_written_here`: stored `caldav_source_id`/`caldav_calendar_href`, or a `booking_write_copies` row), and that function asks `CalendarProvider::event_exists_anywhere` (Graph: the stored calendar, then every other writable calendar the account lists, skipping ones that answer 403/404; an empty listing, one without the stored calendar, or any other error is inconclusive, and inconclusive checks lasting over a day fail the sync via `caldav_sources.orphan_check_inconclusive_since`), so an event moved to another calendar never cancels a booking. An orphan cancellation deletes the booking's other copies through `caldav_delete_booking`. There is no sweep for bookings never cached on providers, as on CalDAV. A calendar still listed keeps its row and its `sync_enabled` setting even when it is not synced
- **`events`** — cached remote events from CalDAV sync; unique on `(uid, calendar_id, COALESCE(recurrence_id, ''))`, stores `raw_ical`, `etag`, `rrule`, `all_day`, `timezone`, `recurrence_id`, `status`
- **`event_types`** — bookable meeting templates (slug unique per account, team event types included since they are stored under their creator's account, and per team via `idx_event_types_team_slug` (#313); handlers check both scopes with `slug_conflict()`, `section` (free text grouping the type on its landing page, NULL = none) and `page_position` (its place on its owner's or team's page, NULL = after the placed ones in creation order; `landing::PAGE_ORDER`), `duration_min`, `buffer_before`/`buffer_after`, `min_notice_min`, `location_type`/`location_value`, `requires_confirmation`, `visibility` (public/internal/private), `max_additional_guests`, `group_id` (legacy), `team_id` (unified teams FK), `created_by_user_id`, `reminder_minutes`, `scheduling_mode` (round_robin/collective), `default_calendar_view` (month/week/column), `first_slot_only` (boolean), `pending_blocks_slot` (pending requests block the host's time, default 1), `pending_expiry_hours` (NULL = requests never expire), `booking_horizon_days` (NULL = unlimited), `bookable_from`/`bookable_until` (YYYY-MM-DD in the event type timezone, inclusive, NULL = no bound; combined with the horizon in `booking_window.rs`), `guest_email_footer` (markdown footer, max 500 chars, added to guest emails, replaces the instance default, NULL = use the default), `notes_mode` (off/optional/required, #210))
- **`event_type_questions`** / **`booking_answers`**: booking questions per event type and each booking's answers, with a snapshot of the question as the guest read it (#210, see "Booking questions" below)
- **`availability_rules`** — weekly recurring windows per event type (day_of_week 0=Sun…6=Sat, HH:MM times)
- **`availability_overrides`** — date-specific exceptions (day off, special hours). `is_blocked` flag
- **`user_time_off`**: per-user periods with no bookings on any event type they host (#46): `user_id` (`ON DELETE CASCADE`), `start_at`/`end_at` (UTC `YYYY-MM-DDTHH:MM:SSZ`, end exclusive), `timezone` (zone it was entered in, for display only), `label` (private to the user)
- **`bookings`** — bookings with `uid` (iCal), guest info, status (confirmed/pending/cancelled/declined), `cancel_token`/`reschedule_token`/`confirm_token`, `assigned_user_id` (for group round-robin), `caldav_calendar_href` (write-back tracking), `reminder_sent_at` (tracks when reminder email was sent), `pending_expires_at` (UTC deadline after which a pending request is declined, NULL = never), `pending_since` (start of the current pending window, anchors the halfway host nudge), `pending_reminder_sent_at` (that nudge went out), `first_confirmed_at` (set by trigger the first time the booking is confirmed; additional attendees get the "added" email only then, and the moved invite on a confirmed reschedule), `ics_sequence` (SEQUENCE of the invite, bumped by each reschedule and by the cancellation of a confirmed booking, so a CANCEL outranks the copy it ends, and carried by every invite, CANCEL and push), `host_notes` / `host_notes_version` / `host_notes_updated_at` / `host_notes_updated_by` / `host_notes_updated_as` (host-only notes, #173, see "Host notes" below)
- **`smtp_config`** — SMTP server settings (host, port, credentials, sender), one per account
- **`event_type_calendars`**: junction table linking event types to specific calendars for per-event-type calendar selection. Empty = use all `is_busy=1` calendars (backward-compatible default); a selected calendar counts whatever its `is_busy`
- **`event_type_write_targets`**: per-(event type, user) write destination (#47): `calendar_id` (FK calendars, `ON DELETE CASCADE`), so removing the source drops the choice. No row = the source setting (`write_calendar_href` of each source). **`booking_write_copies`** records each copy written to a destination (`booking_uid`, `user_id`, `source_id`, `calendar_href`), so later pushes, cancels and reschedules find it where it was written; pushes under the source setting record nothing
- **`booking_invites`** — tokenized invite links for internal/private event types: `token` (unique), `event_type_id`, `guest_name`, `guest_email`, `message`, `expires_at`, `max_uses`, `used_count`, `created_by_user_id`
- **`booking_attendees`** — additional attendees per booking: `booking_id` (FK), `email`, `created_at`, `added_by_claim` (the row a watcher's claim added: staff, never gets the guest footer, even after the claimant is deleted), `claimant_user_id` (whose row it is while that user exists, `ON DELETE SET NULL`)
- **`teams`** — unified teams replacing both OIDC groups-as-scheduling-units and ad-hoc team links. Fields: `name`, `slug` (unique), `description`, `avatar_path`, `visibility` (public/private), `invite_token` (for private teams), `created_by`
- **`team_members`** — team membership: `team_id`, `user_id`, `role` (admin/member), `source` (direct/group). Source tracks whether membership comes from direct assignment or OIDC group sync
- **`team_groups`** — links teams to OIDC groups for automatic member sync: `team_id`, `group_id`
- **`event_type_member_weights`** — per-event-type round-robin priority: `event_type_id`, `user_id`, `weight` (higher = assigned first)
- **`event_type_favorites`**: personal starred meetings for the booking directory and dashboard (migration 078): `user_id`, `event_type_id` (both `ON DELETE CASCADE`), `created_at`. Visibility is rechecked on every read by `directory_page()` in `src/web/directory.rs` (`is_listed()` for one id), so a favorite never grants access. Search, filter and paging (50 per page, 12 favorites on the overview) run in SQL; text search goes through `listing::ci_glob` (bound `GLOB` patterns, each character matching its case forms in every alphabet, `%`/`_`/`*` literal; control characters dropped, at most 8 distinct words), and closed booking windows are cut in SQL except today's and yesterday's, decided per zone in Rust (#340)
- **`booking_frequency_limits`** — per-event-type booking caps: `event_type_id`, `max_bookings`, `period` (day/week/month/year)
- **`groups`** / **`user_groups`** — preserved for OIDC identity sync from Keycloak. Groups are no longer used directly for scheduling; teams reference groups via `team_groups` for automatic member sync. `user_groups.weight` for round-robin priority
- **`webhooks`** / **`webhook_deliveries`**: outgoing booking webhooks (#168). `webhooks`: `user_id` (who added it; `ON DELETE SET NULL`, and a trigger deletes the user's personal-scope webhooks first), `event_type_id` (NULL = all of that user's personal event types), `url`, `secret_enc` (AES-256-GCM), `events` (comma-separated names, NULL = all), `enabled`, last attempt/status/error/success. `webhook_deliveries`: the queue, `seq` (order), `id` (idempotency id sent with every attempt), `booking_uid`, `event`, `payload` (JSON snapshot taken at enqueue), `state` (pending/failed), `attempts`, `next_attempt_at`. Delivered rows are deleted; pending ones become failed after 24 h; failed ones are pruned after 7 days; the pending rows of a webhook that stops sending are dropped (`drop_inactive_queues`, every pass and on the admin switch). `auth_config.webhooks_allow_all_users` (default 0: admins only)
- **`team_links`** — legacy table, migrated to private teams by migration 034. No longer used by the application
All primary keys are UUID v4 strings. Datetimes are ISO8601 strings.
---
## CalDAV client
File: `src/caldav/mod.rs`
The client is intentionally minimal — enough to be useful, not a full RFC 4791 implementation.
**Discovery flow** (three-step, RFC 4791 compliant):
1. `discover_principal()` — PROPFIND Depth:0 on base URL, extracts `` href
2. `discover_calendar_home(principal)` — PROPFIND Depth:0 on principal, extracts `` href
3. `list_calendars(home_url)` — PROPFIND Depth:1 on calendar home, filters to `` resource types only
**Other methods:**
- `check_connection()` — OPTIONS request, verifies `calendar-access` in DAV header
- `fetch_events(calendar_href)` — REPORT with `calendar-query` filter for VEVENTs (60s timeout)
- `fetch_events_since(calendar_href, since_utc)` — REPORT with RFC 4791 `time-range` filter (only future events). Falls back to full fetch if the server rejects the time-range query.
- `put_event(calendar_href, uid, ics)` — PUT a VEVENT to the calendar (write-back)
- `delete_event(calendar_href, uid)` — DELETE a VEVENT from the calendar
**URL resolution:** All hrefs from the server are resolved via `resolve_url()` which uses the server origin (scheme + host), not the base URL path, to avoid path duplication.
**XML templates** are `const &str` at the bottom of the file (PROPFIND_PRINCIPAL, PROPFIND_CALENDAR_HOME, PROPFIND_CALENDARS, REPORT_CALENDAR_DATA).
**Timeouts:** 10s default for discovery/metadata requests, 60s for event fetches (calendars can have thousands of events).
**Tested with:** BlueMind (4000+ events). Handles both `aic:` and `x1:` namespace prefixes for calendar colors, `cso:` and `cs:` for ctags.
**XML parsing (#325):** responses are parsed with `quick-xml` into a small namespace-aware tree (`src/xml.rs`), and `src/caldav/multistatus.rs` reads the multistatus from it: principal, calendar-home-set, calendar listing, REPORT events, sync-collection deltas, and the depth:0 existence check. Elements are matched by namespace URI and local name (`Element::is`), never by prefix, so `d:`, `D:`, `a:`, a default namespace or one declared on the element itself all read the same; a name under an undeclared prefix matches on its local name (old tolerance, and many test fixtures rely on it). Exception: `calendar` (in a resourcetype) and `calendar-data` are read in any namespace (`caldav_local_name`, one warning per process), since servers write them in DAV: and no other namespace defines them. Properties come only from 2xx (or status-less) propstats, first non-empty value wins; the resource-level `` (direct child of ``) is the only one that marks a sync deletion (#107). Malformed XML, an empty body, or a well-formed body that is not a multistatus, is an error, never "no events" (which would let the sync store the new ctag without having read anything). The client accepts only 207, or 200 with a multistatus body, on PROPFIND and on the REPORTs the sync acts on (`multistatus_body()` in `src/caldav/mod.rs`); a non-2xx answer to the time-range REPORT still falls back to the unfiltered one, and 403/405/412 on sync-collection still read as "token invalid". Status errors name the code and reason only, never the body (an error page on a shared calendar can quote other people's events, and the message is stored). A sync-collection truncated by a 507 on the collection (RFC 6578 3.6) sets `SyncResult::truncated`: the sync stores the new token but keeps the old ctag, so the next sync continues. Bodies are read through `read_body()`, capped at `xml::MAX_INPUT_BYTES` (64 MB) as they arrive, decoded with the `Content-Type` charset, and parsed on the blocking pool (`parse_blocking`). XML bounded at 128 levels, 1M elements (names and namespaces interned, about 120 MB of tree at the cap) and 256 namespace declarations in scope (quick-xml resolves names by scanning them); reading stops when the root closes (sabre/dav installs with PHP `display_errors` append warnings after it), text before the root is an error; the DOCTYPE is skipped unread and only the predefined entities and character references (`..;`, `..;`, `..;`, NUL and invalid code points kept literal) are decoded. Server-controlled names in errors are cut to 64 characters. Hrefs are kept as written (percent-encoding and absolute URLs untouched). Elements keep their unprefixed attributes (`Element::attr`, decoded like text). EWS reads its SOAP replies from the same tree (#369): `soap::read_reply` turns a body into its `Envelope` (SOAP 1.1 or 1.2, any prefix) or an error (malformed, empty, not an envelope, a 500 without a fault), raises a `Fault` (200 or 500) or any response message whose `ResponseCode` is not `NoError` as `SOAP_FAULT_PREFIX` + `Code: text` (GetItem batches pass `ErrorItemNotFound` through `post_soap_skipping` and drop that item; `Warning` with `NoError` is a success for both layers), parses on the blocking pool (`caldav::parse_blocking`), and `src/ews/parse.rs` reads each operation's message by local name in either EWS namespace (`soap::is_ews`). A missing message, a `ResponseClass` other than `Success`, a missing `RootFolder`/`Items`/`Folders`, an item or folder without its id, a non-boolean flag, or an item whose Start/End is not a date-time is an error, never an empty result. A UID with control characters (organizer-controlled, so refusing would let an outsider freeze the sync) is replaced by `ews-` + hex SHA-256 of it (`parse::safe_uid`, same value on every read); both go verbatim into the VCALENDAR `ews::ical::synth_vcalendar` makes, which re-checks them. Autodiscover matches `EwsUrl`/`ASUrl` by local name. Fixtures shaped after Exchange 2016/2019 replies live in `src/ews/fixtures.rs`.
**iCal parsing:** `split_vevents()` and `extract_vevent_field()` in `utils.rs` split multi-VEVENT CalDAV blobs (e.g. BlueMind recurring events with modified instances) into individual VEVENT blocks and extract fields. Used by both CLI sync and web sync. Dates are stored as-is from iCal: `YYYYMMDD` for all-day events, `YYYYMMDDTHHMMSS` for timed events.
**Multi-VEVENT sync:** CalDAV resources may contain multiple VEVENTs (parent with RRULE + modified instances with RECURRENCE-ID). The sync splits them and stores each as a separate row with a composite unique key `(uid, COALESCE(recurrence_id, ''))`.
**Recurrence expansion (#376):** `rrule::occurrences_in()` is the one expansion every busy reader uses (slot engine lenient and strict, resources, troubleshoot, CLI slots and booking create, overview): it expands in the event's zone (a `Z` UNTIL read into it) over the window widened by two days, converts, then clips in the host's zone. `expand_rrule` starts at the window rather than DTSTART (it skips ahead, counting skipped occurrences arithmetically for `COUNT` when no `BY*` part thins them, else counting from DTSTART up to `MAX_COUNTED_PERIODS`), capped at `MAX_PERIODS` per window. DAILY to YEARLY with BYDAY, BYMONTH, BYMONTHDAY, BYSETPOS and WKST are expanded, DTSTART always being the first instance; any other part (`rrule::is_supported` false) fails closed: DTSTART's time every day from DTSTART (BYYEARDAY, BYWEEKNO, RSCALE, unknown parts), or all the time from DTSTART (sub-daily FREQ, BYHOUR/BYMINUTE/BYSECOND, no FREQ), bounded by UNTIL; a walk that hits its cap blocks the rest of the window. Labelled in troubleshoot, warned once per event per sync by `warn_if_unexpandable`. A floating event's `Z` UNTIL is read in the host zone. RDATEs (`extract_rdates`, every value form) join the set in `occurrences_in` before EXDATE/RECURRENCE-ID removal and never count toward COUNT; sync sets `events.rdate`/`resource_events.rdate` on a series row that has one, and busy queries treat `rrule != '' OR rdate = 1` as recurring (selecting `COALESCE(rrule, '')`), the rest as single events.
---
## Authentication & authorization
File: `src/auth.rs`
**Local auth:** Argon2 password hashing. Server-side sessions stored in SQLite with 30-day TTL. HttpOnly cookies (`calrs_session`).
**Local MFA (#213):** TOTP two-factor authentication for local accounts, in `src/mfa.rs` (migration 075: `user_mfa`, `mfa_recovery_codes`, `mfa_challenges`, `mfa_attempts`, `auth_config.mfa_required`, `sessions.mfa_verified`). A local login with MFA enrolled, or with MFA required, gets a short-lived challenge instead of a session (`mfa::begin_login`); no session is issued until the code is verified. Every local login and registration goes through `begin_login`; only the OIDC callback calls `create_session` directly, since SSO accounts use the identity provider's MFA. When the admin requires MFA, `validate_session` rejects local sessions that have not passed it. Wrong codes are throttled per account (`reserve_attempt`): ten in a row are free, then one try per wait, starting at 15 minutes and doubling every further ten failures up to a day; a success clears the count, recovery codes are never throttled (128 bits, and the owner's way in while a password holder burns the budget), and every refusal is logged with the user and IP. Suspending a user (`auth::set_user_enabled`), `calrs user set-password`, and linking an account to OIDC revoke its sessions, pending challenges and OAuth grants (MCP clients, #273) in the same transaction (`mfa::revoke_access`); OAuth grants also apply the same MFA policy as `validate_session` on every token use (`GRANT_MFA_POLICY` in `src/oauth.rs`); automatic OIDC linking by email is refused for an account with local MFA. Operator recovery: `calrs user reset-mfa `.
**OIDC:** OpenID Connect via `openidconnect` 4.x crate. Authorization code flow with PKCE (S256). State, nonce, and PKCE verifier stored in short-lived cookies during the flow. Tested with Keycloak.
**User linking:** On OIDC callback, tries: (1) match by `oidc_subject`, (2) match by email (links existing local user), (3) auto-register if enabled. On login, `groups` and `title` JWT claims are extracted via `extract_claims_from_id_token()` and synced to the user record.
**Extractors:** `AuthUser` (redirects to login if not authenticated), `AdminUser` (returns 403 if not admin). Both implemented as axum `FromRequestParts`.
**Login/register redirect:** If the user is already authenticated, visiting `/auth/login` or `/auth/register` redirects to `/dashboard` instead of showing the form.
**URL scheme:** User-scoped public booking URLs: `/u/{username}/{slug}`. Legacy single-user routes (`/{slug}`) kept for backward compatibility.
---
## Web UI
Files: `src/web/` (see `ARCHITECTURE.md` for which file owns which page), templates in `templates/`
**Sidebar layout** (`dashboard_base.html`): All authenticated pages use a two-column layout with a persistent left sidebar (260px). Sidebar shows user avatar (with initials fallback), name, title, and organized nav sections. Mobile responsive with hamburger menu. All dashboard sub-pages pass `sidebar => sidebar_context(&auth_user, "active-page")` to their template context.
**Cards:** dashboard blocks are cards, not sections under rules (owner's choice, 2026-11-09). Use the shared component in `dashboard_base.html` (`.card` > `.card-head` with the `h2`, its `.num` count and a `.card-action`, then `.card-body`; `.card-part` with `.card-subhead` for a second titled part; `.card.is-now` for the one "now" card) and put a page made of cards on `--bg` by filling its `main_class` block with `has-cards`. Every list page is one card: its search and filters in the head band (`.card-head.is-tools`, the search from the `search` macro of `partials/card_list.html`), the rows in the body, the pager in a `.card-foot`, and `` with `` is a collapsible card. Pages in cards: the overview, event types (list, form, overrides, embed), sources (list, add, calendars, test), directory, time off, settings, webhooks, API tokens, connected apps, troubleshoot, API docs and host notes; bookings, invites, teams and the admin panel still use rules. Forms group fields into cards by topic and keep the save bar outside them.
**Dashboard** — split into focused pages, each extending `dashboard_base.html`:
- `/dashboard`: overview, a greeting with one sentence about the day, the next meeting as the one large block (guest face, countdown in words, Join), a quiet "N things to set up" line, pending requests, a 7-day week strip, a 30-day sentence with the 12-week bars. Guest faces are initials on a warm hue from the name (`initials()`/`face_hue()` in `overview_stats.rs`). Faces mix the hue into the theme's `--surface`, so every preset reads. The default palette in `base.html` is warm (paper, greys, a warm charcoal in dark mode); presets and custom themes replace it as before
- `/dashboard/event-types` — Personal + team event types (create/edit/toggle/delete/view)
- `/dashboard/bookings` — Pending approval + upcoming bookings (cancel with optional reason), "Last 30 days" (meetings held) and "Cancelled and declined" (future ones and the last 30 days); both are scoped by `NOTE_HOSTS` (`booking_notes.rs`) and their titles open the host notes page (`hosted_bookings()` in `dashboard.rs`)
- `/dashboard/sources` — Calendar sources (add/test/sync/remove/write-back)
- `/dashboard/teams` — Teams listing (create/edit/manage members/delete)
- `/dashboard/invite-links` — Internal event types (personal + team) visible to authenticated users, with quick invite link generation (renamed from `/dashboard/organization`)
**Admin panel** (`/dashboard/admin`): User management (promote/demote, enable/disable), auth settings (registration toggle, allowed domains), OIDC config, SMTP status, groups overview, impersonation. Requires `AdminUser`. The Users tab is always searched, filtered, sorted and paged on the server (`UserListState` in `admin.rs`, params `uq`/`uf`/`us`/`ud`/`up`, 25 rows), and the Groups tab too (`GroupListState`, `gq`/`gp`, 25 rows; one group's members with `gm`/`gmp`; the member weight form posts a `return_to` checked by `listing::return_path`). User actions post a `back` field that is parsed and rebuilt, never echoed. The overview's booking-wide figures are cached for 60 s in `AppState.admin_figures` (`admin_stats::FiguresCache`) only on instances with more than 20,000 bookings (`FIGURES_CACHE_MIN_BOOKINGS`), so small instances stay live; the expiring-requests count behind the attention list is always live (#343).
**Public pages:** User profile (`/u/{username}`), team profile (`/team/{slug}`), time slot picker (Cal.com-style 3-panel layout with switchable month/week/column views), booking form (with optional additional attendees), confirmation page. Event types support location (video link, phone, in-person, custom). Dark/light theme toggle on all public pages. Legacy `/g/{group-slug}` URLs redirect to `/team/{slug}`.
**Landing pages** (`profile.html`, `team_profile.html`, partials `landing_head.html` (CSS), `landing_about.html` (the practical block), `landing_list.html` (cards), `landing_row.html` (one meeting), `landing_dial.html`, `landing_hue.html`, `landing_script.html`; data in `src/web/landing.rs`): Archivo, the host's card on a tint of the brand accent. Beside the name (under it on narrow screens), a block with only what is set, each with a Phosphor icon: the usual hours (`landing::usual_hours` over the weekly rules of the listed types, which must share one timezone, else no line: a timetable of up to four schedules (`TIMETABLE_MAX_ROWS`), more as "hours vary by day"; a "My time" toggle, shown only when the visitor's zone differs, rewrites the rows in it via Intl), `users.based_in`, and `users.spoken_languages` named in the visitor's language (`language-name-{code}`, joined with `list-and`; Spanish picks "e" before an i sound). Meetings sit in A2 cards (`.card`/`.card-head`/`.card-body`, scoped as `.lp-cardlist` because base.html's `.card` differs): `landing_cards` groups them by `event_types.section` in `PAGE_ORDER`, one card per section and the unsectioned last ("Other meetings"), or one "Meetings" card when no type has a section; "how it works" shows above when more than one. Each row: dials of the length (one per hour, at most three, `duration_label()` label and `duration_words()` aria), the location type, "Host confirms" (`landing-host-confirms`) for `requires_confirmation`, and on team pages the roster (`team_member_ids`, members' `first_name`). The order is set at `/dashboard/event-types/order` and `/dashboard/teams/{id}/order` (team admins or global admin): up/down POSTs (CSRF) rewrite every position of that page in one transaction, from that page's own query only. The section is edited in the event type form (`save_section_in`, absent field = unchanged).
**Durations:** `i18n::format_duration` (short label, "1 h 30 min") and `format_duration_long` (words with plurals, whole days as days) are the one formatter for lengths; templates call `duration_label(m)` and `duration_words(m)`. Do not build "N min" by hand.
**Paged lists:** every list that grows with an instance is paged on the server through `listing::paged()` (`src/web/listing.rs`): a COUNT, then `LIMIT`/`OFFSET` over a fixed `&'static str` order that must end with the row id (debug-asserted), 50 rows by default, a page past the end landing on the last one, and `partials/pager.html` under the list. A page holding several lists gives each its own parameter and anchor, and each link keeps the others' pages: the bookings page (`pp`, `cp`, `bp`, `rp`, `xp`, `BookingPages` in `dashboard.rs`; the overview's day links compute their Upcoming page with `upcoming_pages()`), the admin Users (`uq`/`uf`/`us`/`ud`/`up`) and Groups (`gq`/`gp`, `gm`/`gmp` for one group's members) lists, the EWS card (`ewsq`/`ewsp`/`ewsu`). Single lists use `page` (teams, invite links, invites of an event type, the event types page, own and team as one list) or `mp` (team settings members). Searches on paged lists run on the server (`q` on the bookings, event types and Invite Links pages, kept by the page links; the client-side `data-filter` only narrows the page shown). Actions taken on a page return to it through a `return_to` field checked by `listing::return_path()` (a path of this site under a fixed prefix, never another origin); booking actions go through `bookings_return()`. Public team pages and the slot sidebars fetch a capped set of faces plus a total instead. Pickers are not paged yet. The REST API and MCP use cursors, not this.
**Theme toggle:** Class-based dark mode (`html.dark`) with inline `` script for flash-free loading from `localStorage`. Public pages have a sun/moon toggle in the footer. Dashboard users can set System/Light/Dark in Profile & Settings.
**Availability troubleshoot** (`/dashboard/troubleshoot/{event_type_id}`): Visual timeline showing why slots are available or blocked, with event details. Helps debug availability issues.
**Per-event-type calendar selection:** Event type form includes a checkbox for every synced calendar. Selected calendars are stored in `event_type_calendars` junction table. When computing busy times, if no calendars are selected all `is_busy=1` calendars are checked (backward-compatible); a selected calendar blocks whatever its `is_busy`. Only synced calendars (`sync_enabled=1`) are listed or counted; a selection none of whose calendars is synced falls back to every busy synced calendar. The filter is `c.sync_enabled = 1 AND ((c.is_busy = 1 AND NOT EXISTS ) OR c.id IN )` on `event_type_calendars` and is applied in `fetch_busy_times_for_user()`, troubleshoot handler, and CLI commands.
**Availability overrides:** Per-event-type date overrides at `/dashboard/event-types/{id}/overrides` (by event type id, #85: slugs are only unique per account and per team). An old slug URL redirects only when `manageable_event_type_ids_by_slug` finds exactly one event type the user may manage, else 404 (`resolve_overrides_target`). Two types: blocked days (entire day off) and custom hours (replace weekly rules with specific time windows). Overrides are checked in `compute_slots_from_rules()`: blocked overrides skip the day, custom hours replace weekly rules. Also wired into CLI slot computation and troubleshoot view. Stored in `availability_overrides` table.
**Time off (#46):** per-user periods at `/dashboard/time-off` (`src/web/time_off.rs`, core in `src/time_off.rs`) and `calrs user time-off`. The single choke point is `fetch_busy_times_for_user_opts`, which appends `time_off::busy_intervals`, so personal slot pages, round robin (`pick_group_member`, the member is skipped), collective, submit-time re-checks, reschedule and `approval_hosts_free` all see it; the troubleshoot view (`time_off` status, the user's note shown since the view is only ever their own), the overview open hours (`overview_stats::without_time_off`) and the CLI `event-type slots` / `booking create` (`host_time_off`) read it directly. Every query is scoped by `user_id`; guests and teammates never see the note or the reason, and an impersonating admin gets the page read-only (add/delete refused with 403 and logged) with notes hidden, there and in troubleshoot. `time_off::add` enforces the 100-upcoming cap in its INSERT; `parse_period` refuses years past 9999 and starts over five years out; day boundaries are compared as instants (`starts_on_day_boundary`), for zones that change clocks at midnight. A failed read fails closed: slot grids get the whole window as busy (`busy_intervals`), the submit-time booking, reschedule and approval checks and the CLI get an error (`try_busy_intervals`, through the strict busy readers); `local_range` takes the hull of the local times a period covers, so its ends never reverse across a fall-back and only move when they lie inside the repeated hour; a skipped wall-clock time resolves to the first minute after the gap. The CLI checks every eligible collective member's time off. Dynamic group bookings store their co-hosts in `booking_participants` (migration `20261108005717`), and `RescheduleHosts.participants` makes the reschedule grid and the submit check require them free within their working hours and on all their calendars, and `approval_hosts_free` on all their calendars (an approval re-applies nobody's working hours); `user_busy_bookings` counts a co-host busy during their dynamic group bookings. Adding a period cancels no booking. The hourly loop prunes periods ended over 30 days ago.
**Team event types:** Created under a team from the dashboard. Two scheduling modes: round-robin (picks the least-busy available member, with configurable per-member weights) and collective (requires ALL members to be free, with per-event-type member exclusions supported: a member with weight 0 is off the roster and is not made busy by the event type's unassigned bookings; the owner is busy for them only while on the roster, #375; the overview's `HOST_SCOPE` follows the same rule). Public URLs: `/team/{slug}/{event-slug}`. Both create forms (`/dashboard/event-types/new` with a team picked, and `/dashboard/group-event-types/new`) store everything the edit form stores, scheduling mode, member weights and watchers included; `team_create_stores_what_edit_stores` (`src/web/tests/event_type_create.rs`) compares the two column by column. Teams can be public (listed on team profile page) or private (accessible only to members). Team admins can manage members, link OIDC groups, and configure team settings at `/dashboard/teams/{id}/settings`. That page also lists the members' upcoming time off (#46), read-only, in the viewer's timezone and never with the period's label (`time_off::upcoming_for_team`, which does not select it).
**Booking watchers:** Teams can be designated as watchers on a team event type via `event_type_watchers`. On a new booking (or the approval of a new request), watchers are emailed with a tokenized "Claim this booking" link; approving a guest reschedule of a booking confirmed before does not offer it again. Tokens live in `booking_claim_tokens` with an expiry. The first watcher to claim wins; subsequent claims land on `booking_already_claimed.html`. Claimed bookings surface on the watcher's dashboard via `bookings.claimed_by_user_id`. An editor may attach only teams they belong to (any team for a global admin); an attached team they are not in is shown read-only on the edit form (a private one as "A private team") and kept on save whatever is posted (`locked_watcher_teams` in `event_types.rs`). A confirmed claimed booking is busy time for the claimant (#374): `booking_time::user_busy_bookings` is `USER_HOSTS_BOOKING` (who hosts a booking) plus the claim branch, so the claim reaches every grid, submit-time check, reschedule and approval re-check; the troubleshoot view labels a booking the user only claimed. A claim survives a reschedule, so moving or approving a claimed booking also needs the claimant free: `RescheduleHosts.claimant` (set when the claimant is not a host) joins `reschedule_busy_source` and `approval_hosts_free`, all their calendars, the booking's own row and copy excluded, no working hours.
**Event type visibility:** Three levels controlled by `visibility` column (TEXT: 'public'/'internal'/'private', migration 026). Public event types are listed on profile/group pages. Internal and private are hidden — both use tokenized invite links via `booking_invites`. Internal is available for both personal and team event types. The difference: internal event types allow **any authenticated user** to generate invite links (via the Invite Links page at `/dashboard/invite-links`), while private event types restrict invite creation to the owner. Quick link generation at `POST /dashboard/invites/{id}/quick-link` creates a single-use invite (expires 7 days) and returns JSON with the URL — available both on the Invite Links page and the per-event-type invite management page. The invite token is propagated through the booking flow via query params (`?invite=TOKEN`) and hidden form fields. Guest name/email are pre-filled from the invite (empty for quick links — guest fills them in). Token validation checks expiration and usage limits at every step. The use itself is spent by `consume_invite` (`src/web/booking.rs`), one guarded `UPDATE` inside the booking's transaction that only matches while a use is left and the invite is unexpired (#373), so concurrent submissions cannot overspend it and a failed booking spends nothing. Invite management at `/dashboard/invites/{event_type_id}` includes a "Get link" button at the top for one-click link generation, plus an email form below for sending personalized invites. Invite emails use indigo accent (#6366f1).
**Booking questions (#210):** per event type, edited in the "Booking form" section of the event type form by whoever may edit it (one hidden `questions` JSON field, validated whole by `questions::parse_editor`; posted ids are never trusted, a stored question keeps its key and kind). Five kinds: short text, long text, single choice, multiple choice, checkbox (`consent` flag, optional policy `link_url`). Limits: 20 questions, 30 options of 200 chars, label 200 (1000 for a checkbox), help 500, answers 500/5000. Tables `event_type_questions` (options as JSON with stable ids, `in_calendar`) and `booking_answers` (snapshot of label, value, option ids, consent, link, `in_calendar`, `answered_at`; `question_id` SET NULL on delete), plus `event_types.notes_mode` (off/optional/required). Everything goes through `src/web/questions.rs`: the four booking POST handlers call `read_submission` once (fields `q_` and `q___