Skip to content

Latest commit

 

History

History
1165 lines (1010 loc) · 74.1 KB

File metadata and controls

1165 lines (1010 loc) · 74.1 KB

Calnode — Architecture

Status: living doc. The source of truth is the code; this explains how the pieces fit and why. File references point at packages/symbols (internal/...). New to the codebase? Start here, then see CONTRIBUTING.md for build/test.


1. What Calnode is

A self-hostable scheduling/booking app (Calendly-style) shipped as a single Go binary. The Go server embeds the built SvelteKit admin SPA at compile time and also serves the public, server-rendered booking pages. Persistence is SQLite. Primary focus: self-hosting; the longer-term direction is instance-per-tenant managed hosting (foundational pieces — envelope crypto, host split, readiness gate — are already in place; see §16).

One process, one file (data/calnode.db), no external services required to run (SMTP and Google are optional integrations configured at runtime).


2. Topology — two frontends, one binary

Deliberate split (see CLAUDE.md):

Surface Tech Served at Why
Admin app SvelteKit 2 SPA (Svelte 5, Vite 8, Tailwind 4, shadcn-svelte/bits-ui) /admin/* Rich, authenticated, many interactive pages → a framework SPA earns its weight. Embedded via //go:embed all:build (frontend/embed.go).
Public pages Server-rendered Go html/template + vanilla JS /book/{slug}, /manage/{token} A booker clicking an email link should get instant first paint and a tiny payload — hand-written HTML beats shipping a framework runtime.
Favicon single embedded favicon.svg /favicon.svg, /favicon.ico, /admin/favicon.svg One source (frontend.FaviconHandler) shared by all pages.

Consequence/debt: two styling systems (Tailwind/shadcn in the SPA; hand-written CSS in the Go templates). The two Go templates (book.html, manage.html) are kept visually in sync by hand — manage.html mirrors book.html's styles + calendar/slot JS. If you change one calendar, change both.

Frontend is embedded at compile time. To see frontend changes in the running app you must pnpm build in frontend/ and rebuild/restart the Go binary (make build does both). Restarting Go alone won't pick up new assets.


3. Process, config, startup

  • Entry: cmd/calnode/main.go. Sibling CLI tools: recover_key.go, rotate_key.go, reset_admin.go (key escrow recovery, KEK rotation, admin reset).
  • Config: internal/config — env-driven. Key vars:
    • PORT (3000), DATABASE_URL (sqlite://./data/calnode.db)
    • BASE_URL — identity host (OAuth callbacks, admin UI, invites)
    • PUBLIC_BASE_URL — booker-facing host (booking links, emails); defaults to BASE_URL. The split lets a tenant put the team on a custom domain (book.acme.com) while OAuth/admin stay on the identity host (see §16).
    • CALNODE_ENCRYPTION_KEY (platform secret / KEK input), CALNODE_RECOVERY_SECRET (escrow)
    • SMTP_* and GOOGLE_CLIENT_ID/SECRET (also settable at runtime in DB settings, which take priority)
    • MICROSOFT_CLIENT_ID/SECRET and MICROSOFT_TENANT (default common; use the multi-tenant common so any work/personal Microsoft account can connect/sign in)
    • COOKIE_SECURE (defaults true when BASE_URL is https)
  • Startup (internal/server/server.go: New): open DB → run goose migrations → open keyvault (unwrap DEK) → configure mailer (DB settings override env) → start webhook/reminder worker → load Google creds (DB > env) → build one calendar.Service and register every configured provider (Google, Microsoft) → wire up the matching OAuth login providers (Google and/or Microsoft) → if any calendar is configured, start the calendar reconciler → register routes → return handler + a drain func.
  • Ops endpoints: GET /healthz, GET /readyz (readiness gate), GET /version (build stamp from internal/buildinfo).
  • Graceful shutdown drains the worker and in-flight requests before db.Close().

4. Persistence (SQLite) — and the single-connection rule

  • internal/db: opens SQLite with SetMaxOpenConns(1) + SetMaxIdleConns(1), WAL journal mode, busy_timeout=5000. One writer connection by design.
  • Migrations: goose SQL files in internal/db/migrations/ (00001→00029). Run automatically on startup. ALTER TABLE ADD COLUMN is reversible-by-convention only (SQLite can't easily drop columns).

⚠️ The single-connection gotcha (bit us once)

With one connection, never run a query while a rows cursor from the same pool is still open (i.e. inside a for rows.Next() loop). The open cursor holds the only connection; the inner query waits for a connection that never frees → deadlock until context deadline, surfacing as a confusing context deadline exceeded (not "database is locked"). Pattern: read the cursor fully into a slice, close it, then loop. See Handler.assignedHosts, the calendar reconciler, Reschedule. (Memory: sqlite-single-connection.)

Bonus property: because booking transactions serialize on the single connection, the app-level overlap check reliably guards all hosts (not just the one the partial unique index covers) — no TOCTOU between concurrent bookings.

Data model (key tables)

  • Identity/authz: users (+ is_admin, is_owner, archived_at, archived_by, plus prefs time_format/week_start/date_format, seven notify_* toggles, and auth columns email_login/password_hash/provider/ provider_id), sessions (cookie auth), api_keys (SHA-256 hashed), invite_tokens (hashed, single-use). There is no auth_providers table — OAuth identity is columns on users (migration 00012).
  • Teams: teams, team_members (with routing_priority).
  • Event types: event_types (+ routing_mode — CHECK has four values: fixed | round_robin | collective | priority; rr_strategy; max_active_bookings (per-invitee cap, enforced); seat_limit (group/class — column exists but is not enforced); team_id — vestigial for routing), event_type_hosts (the host-roles table), event_type_questions (intake form), event_type_reminders (per-ET hours_before, UNIQUE).
  • Availability: availability_rules (weekly), availability_overrides (dated).
  • Bookings: bookings (primary host_id, external_event_id, status), booking_hosts (every attending host + is_primary + per-host external_event_id), booking_attendees (organizer + invitees), booking_answers, booking_manage_tokens (PK is the hashed token; TTL is app-level, set by IssueManageToken, not a schema constraint). Note: bookings.host_id and booking_hosts.user_id carry no ON DELETE clause (NO ACTION), and users.archived_by is a bare TEXT column (no FK) — which is why member offboarding is archive, not delete.
  • Integrations/secrets: calendar_connections (per-user OAuth tokens, encrypted; provider = google|microsoft, is_destination/check_conflicts roles, and account_kind work|personal from migration 00032), server_settings (a singleton row id=1 holding SMTP + Google creds — encrypted smtp_pass_enc, google_client_secret_enc), crypto_keystore (wrapped DEK + recovery escrow), webhooks + webhook_deliveries, jobs (worker queue).

All timestamps are UTC. Times are rendered in the host's / booker's local tz at the edges only.


5. Security & crypto (envelope encryption)

internal/keyvault + internal/secret:

  • A random DEK (AES-256) encrypts secret columns via AES-256-GCM (random 12-byte nonce). The encrypted columns have grown with each integration — as of migration 00045, that's 13 column instances across 4 tables: calendar_connections.access_token_enc + refresh_token_enc, zoom_connections.access_token_enc + refresh_token_enc (its own table — Zoom is a meeting-link provider, not a calendar), webhooks.secret_enc (webhook signing secret), and eight server_settings columns: smtp_pass_enc, google_client_secret_enc, zoom_client_secret_enc, llm_api_key_enc, stripe_secret_key_enc, stripe_webhook_secret_enc, livekit_api_secret_enc, stt_api_key_enc (Deepgram). Grep _enc across internal/db/migrations/*.sql for the current authoritative list — new integrations keep adding columns here.
  • The DEK is stored wrapped in crypto_keystore, encrypted by a KEK = Argon2id(CALNODE_ENCRYPTION_KEY) — params 64 MiB / 3 iterations / 2 lanes, 16-byte salt; the wrap is AES-256-GCM. A second wrapped copy is escrowed under CALNODE_RECOVERY_SECRET — but only if that env was set at first boot.
  • On startup the vault unwraps the DEK ("DEK unwrapped from keystore (primary)").
  • CLI tools: rotate_key re-wraps the same DEK under a new platform secret (it does not rotate the DEK or re-encrypt columns); recover_key unwraps via the recovery escrow; reset_admin is a bcrypt password reset (not part of the crypto subsystem).
  • Dev caveat: with no CALNODE_ENCRYPTION_KEY and a non-https BASE_URL, the vault generates an ephemeral per-process DEK (so any persisted *_enc data is unreadable across restarts). Production (https BASE_URL) hard-fails without the key.

So the DB at rest contains only ciphertext + a wrapped key; losing the DB without the platform/recovery secret doesn't expose secrets.


6. Auth, sessions, roles

  • API keys (cno_…, SHA-256-hashed in api_keys) and browser sessions (cookie calnode_session, HttpOnly/SameSite=Lax/Secure-when-https, 30-day, stored in sessions) both satisfy RequireAuth (internal/handler/auth.go). Note: a present-but-invalid API key is rejected outright — it does NOT fall through to the session path (confused-deputy guard).
  • Login paths: Google OAuth (/v1/auth/login/v1/auth/callback), Microsoft OAuth (/v1/auth/microsoft/login/v1/auth/microsoft/callback), and email+password (email_login + bcrypt). Both OAuth flows are identity-only (Google openid email profile; Microsoft openid email profile User.Read — calendar access is a separate connection with its own scopes), share helpers in auth_oauth.go (newOAuthState/verifyOAuthState/finishOAuthLogin), map a user by email, and cannot create users (unknown email → no_account). Microsoft needs its own redirect URI registered in Azure (/v1/auth/microsoft/callback), distinct from the calendar one. (Magic-link login is also shipped — POST /v1/auth/magic-link/request + GET …/verify, magic_link.go, migration 00040 — alongside password auth.) The actual user-creation path for non-first users is invites (invite_tokens), not OAuth self-registration.
  • CSRF: cookie sessions are SameSite=Lax (blocks the classic cross-site write), plus a SameOriginCheck middleware (internal/server) that rejects a state-changing request whose Origin/Referer host ≠ the request Host — but only when the calnode_session cookie is present, so public booking, API-key, and manage-token requests are untouched. API-key auth isn't CSRF-able (custom header).
  • Roles: Member / Admin / Owner. is_owner is additive over is_admin. Owner-gated actions: grant/revoke admin, transfer ownership. Admins can cancel any booking, see all bookings, manage teams/members. Safe-removal + archive guards prevent orphaning.
  • Offboarding = archive (users.archived_at), never hard-delete — preserves bookings, event-type ownership, team links. Archived ⇒ no login, hidden from lists, skipped in routing/slots, event types deactivated. Reversible (restore). Archiving is blocked while the member has upcoming (primary-host) bookings; a resolve-meetings flow makes the admin reassign/cancel each first.

7. Routing — the host-roles model

An event type owns a host list (event_type_hosts): each row = (user, role, priority), role ∈ required | rotation | optional. The editor authors these roles through two plain questions rather than a mode picker — who can host? (just me / specific people) and, for people, how are they staffed? (rotate / everyone attends). routing_mode is derived from the two answers, never set directly (frontend/src/routes/event-types/[slug]/+page.svelte):

Q1 Q2 routing_mode Roles written
Just me fixed owner → required
Specific people Rotate round_robin each → rotation (+ rr_strategy)
Specific people Everyone attends collective each → required; per-person Optional toggle → optional (join-if-free)

Everyone is required by default in the "Everyone attends" branch, so the common case has no extra knobs; flipping a person to Optional is the only refinement. The old "fixed host inside a rotation" combo (a required host alongside a rotation pool) is no longer authorable from the UI — the engine still supports it, but no editor path writes it.

The one rule — a slot is offered when: all required hosts free AND (if a rotation pool exists) ≥1 rotation host free. At booking time the assignment is: all required + one rotation pick (by strategy) + every free optional; always ≥1 attendee.

rr_strategy: even (least-loaded — fewest upcoming confirmed via this event type; leastLoadedHost), priority (lowest priority number free first), soonest (falls back to even at assignment — the slot is already fixed). Free rotation candidates stay in priority order; pickRotationHost switches on strategy (internal/booking/service.go).

Note: the routing_mode column actually carries four values — the schema also defines a distinct priority mode alongside fixed/round_robin/collective (ranked single-host) — though the two-question editor only ever writes the three in the table; ranked selection within a rotation is expressed via rr_strategy = priority.

Teams are just a one-click way to populate the host list; event_types.team_id is vestigial for routing. The resolver (resolveEventTypeHosts) excludes archived members.


8. Slot generation

  • Engine: internal/slots/generate.go. Input: []HostAvailability (rules, overrides, busy intervals, Role), EventConfig (duration, interval, buffers, min-notice, max-future, RoutingMode), date range, booker tz, injectable Now.
  • Computes per-host free windows per UTC day, intersects/subtracts busy, aligns to the slot interval, then pickHosts decides per-slot which hosts to surface:
    • collective: all hosts must be free → return all.
    • round_robin: every required (fixed) host free, and one free rotation host (priority order) → return required + the pick. No rotation pick available ⇒ not offered (kept consistent with booking-time assignment, which always needs a rotation pick).
    • fixed/default: the single host if free.
  • Slot interval is NOT the duration. slot_interval_minutes is how often a booking may start; duration_minutes is how long it runs. They are deliberately independent, which is what lets a 45-minute meeting be offered on the hour. New event types default the interval to the duration (a fixed default of 30 was wrong in both directions: a 15-minute event offered slots every 30 minutes, a 90-minute one offered starts it could not honour), and the editor exposes it. Existing event types keep whatever is stored - the default only applies at create. Reported as issue #13, where the invisible setting looked like duration being ignored. slots.Generate rejects a non-positive interval, so both create and update validate it; a 0 would otherwise leave an event type with no bookable times and nothing explaining why.
  • Handler: internal/handler/slots_handler.go: GetSlots. Resolves the host pool by mode (role-tagged), then loads each host's availability concurrently (goroutines) — the slow part is one Google free/busy round-trip per host, so parallelizing turns N sequential calls into ~one call's latency. DB queries serialize on the single connection (fast); only the network overlaps. Response includes a hosts metadata map (id→name/avatar) for rendering faces.
  • Busy for a host = every non-cancelled booking they attend, via booking_hosts (NOT just bookings.host_id) — so a non-primary Group/fixed seat also blocks their availability and prevents cross-event double-booking.
  • Own-event exclusion (§6.2): Calnode's own events also surface in Google free/busy, so the handler subtracts them (slots.SubtractIntervals) — the DB is the source of truth for Calnode bookings. The cut set is this host's bookings with a non-empty booking_hosts.external_event_id (any status). This de-duplicates confirmed bookings and, crucially, frees a slot whose booking was cancelled but whose Google event hasn't been deleted yet — without waiting for the reconciler.
  • Timezone boundary: slots are generated per host-local day but bookings are stored UTC; a morning slot for a +UTC host maps to the previous UTC day. The busy fetch window is widened ±1–2 days so it isn't missed (regression-tested in slots_busy_test.go).

Client calendar perf (book.html / manage.html)

  • Optimistic render: the calendar paints immediately treating every in-window day as available/clickable; when the month's availability returns it re-renders to grey out only days with zero slots. No blocking on the network for first paint.
  • Month-slot cache: the month-availability request already returns every slot for the month — it's cached grouped by day (slotsByDay), so clicking a day reads from memory (instant, zero extra requests). Falls back to a single-day fetch if clicked before the month load lands. Timezone change rebuilds the cache.

Showing booked times ("taken" slots)

event_types.show_taken_slots (migration 00057, default off) opts one event type into rendering already-booked starts struck through instead of hiding them. Requested in discussion #14, issue #19.

  • Why it is opt-in. /slots is public and unauthenticated, so this makes a host's booked hours legible to anyone with the link. Fair for a public-hours use case (an intro call, a clinic), a privacy regression for an instance fronting a team's internal calendars. Never inherited by upgrading; a test asserts the field is absent until chosen.
  • "Taken" is defined by difference, not by reason. slots.GenerateWithTaken walks the range twice - once normally, once with every host's Busy ignored - and reports what the second pass offered and the first did not. This is correct for every routing mode with no special-casing, and it makes three mislabellings structurally impossible: a start outside working hours, one withheld by minimum notice, and one lost to a host pool that cannot satisfy the routing mode are absent from both passes and cancel out. Greying a slot asserts "somebody booked this", so saying it about a time the host does not work would mislead the booker and expose the working day. Buffers do count as taken; the start really is unbookable.
  • Taken slots carry no host ids. The page needs the time; naming who is busy discloses more about an individual than greying requires.
  • Agents never see them. computeSlots takes an explicit includeTaken, and MCP get_available_slots and the booking assistant both pass false. A taken slot in an agent's list is one it will eventually offer, with nothing in the payload to tell it apart. Asserted at the MCP surface with the event type opted in.
  • taken is absent, not empty, when off - a client must distinguish "does not show taken times" from "opted in, nothing booked today".
  • Client side: mergeDaySlots and bookableDayKeys in the shared assets/booking-logic.js, so all three surfaces share one implementation. Free and taken stay separate on the wire and are combined only for display. bookableDayKeys exists for a specific trap: once taken slots are grouped by day too, a fully booked day still produces a key, and using those keys for the calendar would advertise it as having something available. Fully booked days are still openable, deliberately - a list of struck-through times explains itself better than a dead date.

9. Booking lifecycle

internal/booking/service.go (transactions) + internal/handler/booking_handler.go (HTTP + async side effects).

  • Create: in one transaction — overlap-check each candidate (required all free; rotation pick one free; optional join if free) via the booking_hosts-join predicate; enforce the per-invitee active-booking cap (by email, inside the txn); insert bookings (host_id = primary) + a booking_hosts row per attendee (is_primary flag) + attendee + answers. Double-book is guarded by both the app-level check (serialized) and a partial unique index on (host_id, start_at). HTTP 201 returns immediately; side effects run in a background goroutine: per-host Google Calendar event creation (store external_event_id per host), per-host confirmation emails (by prefs), attendee confirmation, webhook, reminders. Idempotency (opt-in): an Idempotency-Key header reserves the request (idempotency_keys, migration 00024); a retry with the same key + body replays the original 201 verbatim instead of creating a duplicate (agent/retry safety), a key reused with a different body → 422, and the key is released on any failure so a genuine retry can proceed. Worker purges keys after 24h (§13). The public booking page sends no key, so this path is inert for normal bookings.
  • List / filter / paginate: booking.ListFilter + booking.List/Counts (internal/booking/list.go) is the one place bookings are selected. It applies visibility (ViewerID pins a member to bookings they host, primary or assigned; empty means the whole workspace and callers gate that on admin), then narrows by event type, host, team, status, date range and upcoming/past, then orders and pages
    • all in SQL. GET /v1/bookings and MCP list_bookings both go through it, and the admin page passes its filters straight down.
    • Why it isn't done in the client: the list used to return every booking the caller could see, then run enrichment queries whose IN clause held every id returned, on the single-connection pool (§17). Pages are capped (bookingsPageMax).
    • Paging needs the index to mean anything. Both pre-existing indexes on bookings lead on host_id and are partial, so a listing planned as SCAN ... USE TEMP B-TREE FOR ORDER BY: the whole matching set sorted to return 25 rows. Migration 00056 adds (start_at, id) and (event_type_id, start_at, id), which turn that into an index walk that stops at the LIMIT. Counts is an aggregate over the match set and still scans; that is inherent, and it replaced a scan that also materialised every row.
    • Counts is a separate query and deliberately ignores When/Limit/Offset, so the Upcoming/Past tab labels describe the whole match set rather than the page. Deriving them from len(items) is the obvious mistake once paging exists.
    • An explicit status replaces the default cancelled-exclusion rather than filtering after it. Both list paths previously hardcoded status != 'cancelled' and filtered on top, so status=cancelled - a value MCP's own schema advertises - could never match anything.
    • Teams resolve through membership, not event_types.team_id: that column exists but nothing writes it, because a team is a shortcut for populating event_type_hosts. A member of two teams therefore appears under both.
  • Public-surface abuse controls: the /slots endpoint is rate-limited (60/min/IP, slotsRL) and POST /v1/bookings (20/min/IP, bookingRL); a per-email hourly cap (maxBookingsPerEmailPerHour) backstops rotating-IP spam; and the booking form has a hidden honeypot (company) that rejects bots. Booker email verification is intentionally absent — it would need a pending-booking state (a deliberate non-goal).
  • Cancel: CancelBooking (admin) and CancelByToken (manage link) share Handler.cancelSideEffects — loops booking_hosts, cancels each host's calendar event by its stored id, notifies each host + the attendee (attendee "With:" = the primary host), fires the webhook. On a successful delete it clears external_event_id (matching the reconciler) so the freed slot is immediately re-offerable and own-event exclusion stops treating it as ours.
  • Reschedule: RescheduleBooking (admin) + RescheduleByToken share moveCalendarEventsService.UpdateEvent moves every assigned host's event to the new time. The overlap re-check covers all the booking's hosts, not just the primary.
  • Reassign: ReassignHost swaps the primary host (archive/resolve flow), moves the calendar event old→new, and keeps booking_hosts in sync so later cancel/notify fan-out targets the right person.

Side effects are best-effort (a mail/calendar failure must not roll back the committed booking) — which is why the reconciler (§11) exists.


10. Calendar integration (provider abstraction)

Calnode talks to calendars through a provider abstraction, not a single vendor:

  • internal/calendar defines the Provider interface (Name, InvitesGuests, AuthURL/EncryptState/Exchange, Connected/Disconnect/HasDestination, FreeBusy, CreateEvent/UpdateEvent/CancelEvent) and a Service that dispatches per-user: it reads calendar_connections.provider for the user and routes the call to the right backend. Booking/slot/reconciler code talks only to *calendar.Service — never a concrete provider.
  • Providers: internal/gcal (Google) and internal/calendar/microsoft (Microsoft 365 / Outlook via Graph). Both implement Provider. One Service is built at startup (internal/server) and each configured backend is Registered.
  • One calendar per user. On a successful connect the callback calls Service.RetainOnly(userID, provider), deleting any prior connection on a different provider — so connecting Microsoft replaces a previous Google connection and vice versa.
  • calendar_connections holds per-user OAuth tokens (encrypted), the provider, connection roles is_destination / check_conflicts, and account_kind (migration 00032 — work|personal|""). Token refresh is automatic via an oauth2 TokenSource wrapped by a savingTokenSource that persists refreshed tokens (and preserves account_kind, which a refresh has no id_token to re-derive).

Per-provider notes.

  • Google (internal/gcal): Meet via conferenceData.createRequest + conferenceDataVersion=1 (returns hangoutLink); CancelEvent treats 410 Gone as success; sendUpdates=all. Free/busy via the freebusy API.
  • Microsoft (internal/calendar/microsoft): Graph /me/events; Teams via isOnlineMeeting:true, onlineMeetingProvider:"teamsForBusiness"onlineMeeting.joinUrl; free/busy via /me/calendarView. account_kind is captured at connect from the id_token tid claim (the consumers tenant 9188040d-…personal, else work) — added via the openid scope. A blanket 404 MailboxNotEnabledForRESTAPI on every /me/* call means the account has no Exchange Online mailbox (not an auth error). Graph errors log their response body to make this self-evident.

Online-meeting links are provider-matched (booking_handler.go). A google_meet/teams event type auto-mints a link only when the primary host's connected provider natively matches the platform — Meet↔Google, Teams↔work-Microsoft (Service.CanAutoGenerate; personal Microsoft can't mint Teams). When it can't, we never fabricate a link of the wrong kind — the organizer's manually-entered location_value is used instead. The minted link is stored on bookings.location_value, surfaced in emails + the manage page, and passed as the location of secondary hosts' events. The reconciler's create path applies the same match rule; reschedule keeps the link (same event id), reassign carries the existing link to the new host. Created async, so the link lands in the email + booking record, not the instant 201.

Save-time location validation (event_type.go + validateLocation). A location type only saves with usable join info: Teams/Meet need an auto-capable connected calendar or a valid manual link (host-checked); Zoom/Video link need a valid https URL (Zoom host-checked); Phone needs a phone number; In-person is free-form. Enforced on create (when location is explicit) and on update only when location actually changes, so editing an unrelated field on a legacy event type never trips it. New event types get a smart default location from the owner's connected calendar (Google→Meet, work-Microsoft→Teams, else Zoom) so the common case is bookable with no manual link.

  • Multi-host: each attending host gets their own event; the per-host event id lives in booking_hosts.external_event_id (migration 00023); the primary's also on bookings.external_event_id for back-compat.

The email .ics gate (easy to miss). Handler.noConnectedDestination (§12) lives in the mailer path, not the provider packages. It must not attach Calnode's .ics for any provider that auto-invites attendees (Google ✓, Microsoft Graph ✓) or recipients get a duplicate invite; the gate keys on Service.InvitesGuests. Plain CalDAV without iTIP scheduling (RFC 6638) does not auto-invite, so a future CalDAV provider would want the .ics — the rule is "no destination whose provider auto-delivers invites," not "no Google."

Adding another provider (Apple/iCloud, CalDAV): implement calendar.Provider, register it in internal/server, set InvitesGuests() correctly (drives the .ics gate above), and extend providerMintsPlatform/CanAutoGenerate if it offers a native meeting platform.


Which calendar a booking is written to

Two layers, and they are easy to confuse:

  • calendar_connections.is_destination picks the account.
  • connection_calendars.is_destination picks the calendar inside that account (connstore.DestinationCalendarID). Empty means "use the account's default", which is every install until someone actively chooses otherwise.

Each provider applies the choice where its own write target lives: Google and CalDAV swap the calendar id (for CalDAV that id is the collection URL); Microsoft posts to /me/events, which is the default calendar by definition, so a chosen calendar means a different URL (/me/calendars/{id}/events). Graph's update/delete address an event by id across all calendars, so only creation is calendar-scoped there.

Saving a sub-calendar destination also moves the account-level destination to that account, or the choice silently does nothing when the picked calendar lives in a different account from the current destination.

booking_hosts.external_calendar_id records where each event actually went (migration 00055). Reschedule and cancel use it rather than re-resolving the current destination - otherwise changing the destination orphans every existing booking: the provider 404s, the booking cancels in Calnode, and the meeting stays on the host's calendar with nothing surfaced. Empty means "resolve the old way", correct for bookings that predate the column. Known limit: this rescues a change of calendar within an account, not a move to a different account, which would need the account recorded too.


11. Calendar reconciler (self-healing)

internal/handler/calendar_reconcile.go. Calendar side effects are best-effort and async; a transient network failure (e.g. DNS blip) can leave the calendar diverged from the booking with no retry. The reconciler closes that gap using booking_hosts as the desired state:

  • Cancel direction: cancelled booking with a lingering external_event_id → delete the event, null the id.
  • Create direction: upcoming confirmed booking, host has a destination calendar, no event id → create it, store the id. A 5-minute grace skips just-created bookings so it can't race/duplicate the inline create.
  • Runs at startup, every 2 min, and on a nudge fired when an inline op errors. Idempotent (re-delete is a 410 no-op; create only fills gaps), so re-running is safe. Service.HasDestination avoids pointless retries for hosts with no calendar.
  • Time-drift (reschedule): a failed inline move (UpdateEvent) flags the host row needs_sync (migration 00025); reconcileReschedules re-applies the booking's time to flagged events and clears the flag. So the reconciler covers create, cancel, AND time-drift — the last via an explicit flag, since drift can't be inferred from booking state without reading Google. Idempotent (re-applying the same time is a no-op).

12. Notifications & email

  • internal/mailer: two transports behind one Mailer interface. The From header = {EmailFromName} <{EmailFrom}> (smtp.go: buildRaw). Configurable in Settings → Email (email_from, email_from_name) or env.

  • Two transports, chosen by credentials, not by probing (handler.BuildMailer, the single place the decision is made - boot and the settings-save path both call it):

    • a Resend API key set (resend_api_key_enc, migration 00054) selects resend.go, which posts to api.resend.com over HTTPS;
    • otherwise an SMTP host selects smtp.go;
    • neither selects Noop.

    This exists because SMTP is not universally available: several platforms (Railway below Pro among them) block outbound SMTP on cheaper plans by dropping packets, so it presents as a hang and then as a credentials problem. No SMTP-layer setting can fix that.

    Selection is deliberately not probe-and-fallback. A startup probe tests reachability at boot rather than at send time, an open TCP port is not a working delivery path, and silently switching transports makes "which path sent this?" unanswerable while masking a genuinely broken SMTP config. Explicit credentials state intent. GET /v1/settings/email returns the live transport, and the admin UI badges it, so filled-in SMTP fields are never mistaken for SMTP delivery.

  • The dial must stay bounded. defaultSMTPTimeout once applied only via conn.SetDeadline, which runs after the dial returns, leaving the dial itself bounded by the OS SYN-retry limit (~2 min). Against a packet-dropping host that stalls the job queue, which shares a single SQLite connection, so one unreachable mail server delays every queued email behind it. Both dialers now carry the deadline (newDialers).

  • ErrUnreachable distinguishes "could not connect at all" from "connected, then rejected", so POST /v1/settings/email/test can name the platform-block possibility instead of reporting a generic failure. Both transports use it.

  • Calendar invites over the API path: the .ics is attached with its full MIME type, text/calendar; charset=utf-8; method=REQUEST. The method parameter is what makes a client render an RSVP-able event rather than a file. Resend's content_type field is set explicitly for this and pinned by TestICSAttachmentKeepsItsMethodParameter, but their docs do not commit to preserving parameters verbatim - if invites ever arrive as plain attachments, check this first; the fallback is to carry the calendar part in the body.

  • Email types: confirmation, cancellation, reschedule, reminder — to attendee and/or host, gated by per-user notification prefs. Custom per-event-type notes + per-event "send test".

  • HTML emails (mailer/html.go): every booking email is sent multipart/alternative — a styled HTML body plus the plain-text version as the fallback. One shared html/template layout (inline styles, table layout, fixed light palette — no CSS vars/<style>/external CSS, for client compatibility) with a per-type "content" block cloned onto it; add-to-calendar links render as buttons, plus a "Manage booking"/"Book again" button. The body carries no brand text (the sender already brands it): the header is a logo-only slot shown only when a logo is set, and there's no footer. Host emails set HideManageLink. RenderBody returns subject+text+html (test-email path).

  • Add-to-calendar: attendee confirmation/reschedule/reminder emails carry Google + Outlook "Add to Calendar" links (BookingData.GoogleCalURL/OutlookCalURL) — always safe (pull-based). Plus a gated .ics invite (mailer/ics.go, BuildICS): attached to both the attendee's and each host's confirmation/ reschedule (METHOD:REQUEST) and cancellation (METHOD:CANCEL) emails, gated per-recipient on that person's host having no connected destination calendar (Handler.noConnectedDestination, provider-agnostic via Service.HasDestination). When a host is connected (Google or Microsoft), that provider already puts the event on their calendar and invites the booker, so attaching our own (different-UID) .ics would duplicate it; when there's no destination, the .ics is how that recipient gets the meeting on their calendar. Stable UID {bookingID}@calnode + non-decreasing SEQUENCE (updated_at unix) lets a client match the REQUEST → reschedule → CANCEL to one event. smtp.go: buildRaw nests the body in multipart/mixed when a message has attachments; the body itself is multipart/alternative (text+HTML) or single-part text.

  • Multi-host fan-out: each assigned host gets their host-notification; the attendee gets one. (See §9.)

  • Per-event-type customisation: custom note bodies (msg_*) and custom subject lines (subj_*, migration 00026) for the four attendee emails; a blank subject falls back to the built-in default (BookingData.SubjectOverride / subjectOr).

  • Branding (branding_settings.go, migrations 00029/00050): instance-wide business_name + logo_url + banner_url on the singleton row. Business name is the wordmark fallback (defaults to "Calnode") + public-page header; the logo is the email header image + public-page header. GET/PATCH /v1/settings/branding (name + opacity settings only); the logo and banner are each an upload (POST/DELETE /v1/settings/branding/logo and .../banner, public serve at GET /branding/logo / GET /branding/banner) reusing the avatar pipeline: imaging.Fit into 600x200 (logo) or 1600x800 (banner) preserving aspect ratio (no crop), re-encoded PNG (keeps transparency), stored on the /data volume. Both URLs store the relative serve path with a ?v=<ts> cache-buster; Handler.applyBranding makes them absolute for emails (relative is fine on same-origin pages). The banner is optional and independent of the logo (each shows or hides on its own presence), rendered full width below the logo on book.html/manage.html (matching .card's 860px max-width) and edge-to-edge in emails (no padding/border, unlike the logo's bordered header cell). Public-page CSP img-src allows https:/data: so the logo/banner load (strictPublicCSP). Brand is threaded into every send site (booking/cancel/reschedule/reassign + worker reminder).

  • Reminders: scheduled as jobs and sent by the worker (§13).

  • Deliverability note (ops): prod sends via Resend SMTP (smtp.resend.com, user resend, password = API key, STARTTLS); orchestratr.ai verified so any @orchestratr.ai From works. Email settings are per-instance in each DB (local ≠ prod). NB: Google/Workspace SMTP rewrites the From to the authenticated account unless it's a verified "Send mail as" alias — that's why a dedicated provider is used for branded From. SPF/DKIM/DMARC for the sending domain drive inbox placement.


13. Webhooks & background worker

  • internal/worker: polls the jobs table every 5s (batch ≤10). Job types: webhook.deliver and reminder.send. Also purges expired manage tokens + sessions + magic-link tokens + idempotency keys (>24h old) + OAuth auth codes + finished webhook deliveries (>30d, webhookDeliveryRetention) each cycle, and reaps jobs whose 30s lock expired (crash recovery — reset to pending +1 min). Retry backoff is a fixed two-step: 60s then 5 min (not exponential), max_attempts 3. Atomic claim via UPDATE … WHERE status='pending' + RowsAffected.
  • internal/webhook: enqueues booking.created / .cancelled / .rescheduled plus the notetaker events recording.completed / transcript.ready / notes.ready (reference payloads — booking-shaped, keyed by id; consumers fetch the artifact body via REST/MCP). There is no booking.reminder webhook event. Deliveries are signed HMAC-SHA256, header X-Calnode-Signature (+ X-Calnode-Event/-Delivery), secret stored encrypted. The worker's HTTP client is SSRF-guarded (resolves DNS, blocks private/loopback/CGNAT/ULA IPs, dials the resolved IP to avoid re-resolution) since webhook URLs are user-supplied.
  • Per-webhook payload fields: each webhook chooses which fields land in the data object (webhooks.fields JSON, migration 00027) — incl. attendee PII + intake answers. NULL ⇒ the original default set (so existing webhooks are unchanged and never start leaking PII). The payload is enriched (enrich) + filtered per-webhook (buildData) at enqueue time, so each subscriber gets its own data. New webhooks default to all fields ticked (self-hoster unticks what they don't want); a delivery-log view (status/HTTP/attempts) is in the admin webhooks page.

14. Visibility model

  • Members see only bookings they hostListByHost matches bookings.host_id OR any booking_hosts seat, so Group/fixed non-primary attendees see meetings they're on (not just the ones they lead).
  • Owners/admins can request the whole workspace: GET /v1/bookings?scope=all (gated on IsAdmin; ignored for non-admins so it can't escalate). The bookings page has a "My / All" toggle for admins with a Host column.

15. Frontend toolchain & conventions

  • Svelte 5, SvelteKit 2 (adapter-static SPA), Vite 8 (Rolldown), Tailwind v4 (@tailwindcss/vite), shadcn-svelte (nova style) + bits-ui, tailwind-variants 3, tailwind-merge v3 (must match Tailwind v4 — v2 mis-merges v4 classes; memory tailwind-merge-version). pnpm only; pnpm exec for local bins (pnpm dlx has a Windows manifest bug).
  • shadcn states styled via Tailwind data-* variants need @custom-variant remaps in app.css or they render silently unstyled — run pnpm test:visual after touching ui/**, app.css, or the theme (memory shadcn-tailwind-variants).
  • Prefer a component's variant prop over a class override of its size default (e.g. button height) — overrides rely on tailwind-merge stripping the default.
  • Control heights are compact h-8 by default ("nova"); auth-screen CTAs use explicit h-11.

16. Deployment & control-plane direction

  • Today: single self-hosted binary + SQLite. make build (frontend then backend), run ./calnode.
  • Direction: instance-per-tenant managed hosting. The foundational pieces are in place — envelope encryption, PUBLIC_BASE_URL split, version stamp, readiness gate. Custom domains: a tenant points book.acme.com at their instance; PUBLIC_BASE_URL drives booker-facing links/emails while BASE_URL stays the identity host for OAuth/admin. Managed SaaS provisioning is later/lower priority.
  • Behind a reverse proxy (Fly / Railway / nginx) — required: forward the original Host header. The CSRF same-origin check (§6) compares the request's Origin/Referer against Host, so a proxy that rewrites Host would false-block admin writes (403). Fly and Railway preserve Host by default; a hand-rolled nginx needs proxy_set_header Host $host;. Related: per-IP rate limits (§8) key on the TCP remote address (proxy headers like X-Forwarded-For are intentionally ignored as forgeable), so behind a shared proxy the limit keys on the proxy's connection — fine for per-instance Fly/Railway, worth knowing for a fronting proxy.

17. Cross-cutting gotchas (read before editing)

  1. SQLite single connection — never query inside an open cursor; materialize first (§4).
  2. All times UTC in storage; convert at the edges. The slot busy-window must be widened for tz boundaries.
  3. Frontend is embedded at compile timepnpm build + rebuild Go to see changes.
  4. Two public templates drift — keep book.html and manage.html in sync.
  5. tailwind-merge must track Tailwind major (§15).
  6. Calendar side effects are best-effort — the reconciler is the safety net; keep ops idempotent.
  7. Booking visibility / availability key on booking_hosts, not host_id alone, or multi-host attendees become invisible / double-bookable.
  8. Public-page CSP is dynamicpublicCSP() (tracking_settings.go) returns the strict default and relaxes only when head code-injection is configured (broad https: or the operator's tracking_csp_allow). Don't re-hardcode the CSP on the book/manage handlers — route it through publicCSP.

18. Known gaps / deferred

  • Multi-host archiving interplay — archive guard / upcoming-bookings / reassign count only the primary host_id; archiving a member who is a non-primary required/fixed host on upcoming bookings isn't blocked. Accepted limitation (degrades gracefully; offboarding is deliberate).
  • BIMI/avatar for outbound email — not settable via headers; needs DMARC enforcement + a VMC if ever wanted.
  • Teams on personal Microsoft accountsteamsForBusiness is work/school only, so a personal Microsoft account can't auto-mint Teams links; the organizer must supply a manual link (validated, and surfaced in the editor hint).
  • The admin SPA does not lay out below ~600px. On the settings pages the nav and the content sit in one flex row with no stacking breakpoint, so the content column is squeezed to near-zero width and text wraps into a tall vertical sliver. Measured on Settings → Profile at a 535px viewport: the mx-auto max-w-4xl px-8 shell reported a 151px client width and the form column 0. Affects every settings page, not one component, so the fix is a breakpoint in the settings shell rather than per-page patching. The public booking surfaces are unaffected and are verified on mobile; this is admin-only. Deferred 2026-08-22, not a regression.
  • LiveKit room is not translated - the booking surfaces, emails and calendar invites ship in 8 languages (§23), but the in-browser meeting UI is ~45 hardcoded English strings. It has its own vanilla-JS asset pipeline and shares no string plumbing with the Go templates, so it needs a small runtime t() of its own. Separate work, not hard.
  • Plural rules are 2-form only (§23) - shipping Polish, Russian or Arabic correctly needs a real CLDR plural-category selector first.
  • OAuth app verification — the Google and Microsoft apps should go through publisher verification before wide public use to avoid "unverified app" warnings.

19. MCP server (agent interface)

A Model Context Protocol server is compiled into the binary on the official Go SDK (github.com/modelcontextprotocol/go-sdk). Handler.MCPServer() (internal/handler/mcp.go) builds one server instance exposing eight typed tools (schema generated from Go structs): list_event_types, get_event_type, get_available_slots, create_booking, get_booking, reschedule_booking, cancel_booking, list_bookings. list_bookings filters (status, date_from, date_to, event_type_id as a slug, host_id, team_id) and pages (limit, offset, with total in the result) through the same SQL path as the REST list (§9) - it used to load everything and filter the slice in Go. get_event_type returns an event type's intake questions (required flags + options) + hosts so an agent can supply valid answers to create_booking — without it the booking tools are subtly unreliable for event types that have required questions.

  • Two transports, one server:
    • stdio — the calnode mcp subcommand (cmd/calnode/mcp.go) boots the full stack via server.BuildHandler (the service-wiring half of server.New, factored out so both paths share it) and runs over mcp.StdioTransport. Logs go to stderr — stdout is the JSON-RPC stream.
    • Streamable HTTP — mounted at POST /mcp in server.New via mcp.NewStreamableHTTPHandler, wrapped in RequireAuth (API-key path is the intended caller; the session path stays same-origin-guarded). No SSE.
  • No parallel code path. Tools call the same internal services as the REST handlers. The shared cores: computeSlots (slot generation, also behind GetSlots), validateAnswersCore (intake-answer validation, behind validateAnswers), resolveBookingHostPool (routing-mode host split), and the side-effect dispatchers dispatchBookingConfirmation / rescheduleSideEffects / cancelSideEffects. So an MCP booking fires calendar events, emails, webhooks, and reminders identically to a web booking.
  • Scope: the management tools are role-scoped to mirror the REST model — MCPCallerMiddleware (after RequireBearerToken) binds the user+role to the request context, and the tools branch on it: members see/act on only bookings they host (ListByHost, host-scoped Cancel), admins/owner get the whole workspace (ListAll/CancelByID). The stdio transport has no bound caller → full access (local operator). list_event_types/get_available_slots/create_booking are the public booking surface (unscoped). Tools translate between the slug they expose as event_type_id and the internal booking id. Gap: create_booking does not yet honour an idempotency_key (REST-only).
  • Authorization — the "Connect" flow (mcp_oauth.go, mcp_oauth_authorize.go): Calnode is its own OAuth 2.1 authorization server for the /mcp resource, so an agent (Claude, ChatGPT) adds the server by URL and clicks Connect instead of pasting a key.
    • /mcp is guarded by auth.RequireBearerToken(VerifyMCPBearer); a 401 advertises /.well-known/oauth-protected-resource (RFC 9728), which points at the AS metadata /.well-known/oauth-authorization-server (RFC 8414).
    • Clients self-register at POST /oauth/register (RFC 7591 DCR; public PKCE clients). GET/POST /oauth/authorize checks for a Calnode session — if absent it bounces through the existing Google/Microsoft login (via a post_login_redirect cookie that finishOAuthLogin honours) and back to a consent screen. POST /oauth/token does PKCE-S256-verified authorization_code and rotating refresh_token grants.
    • Tokens are opaque, SHA-256-hashed in oauth_access_tokens (migration 00033); VerifyMCPBearer accepts either an OAuth access token or a cno_ API key, so scripted callers keep working. The worker purges expired oauth_auth_codes.
    • The slick Connect UX needs the server on HTTPS with valid metadata (deployed instance); http://localhost works for stdio/manual testing but not the remote connector UI.
    • Connected apps admin page (/connections, GET/DELETE /v1/oauth/connections) lists the grants a user authorized and revokes one (deletes the token → immediate loss of /mcp access). Per-user scoped, like API keys. The page also surfaces the connector URL itself (<origin>/mcp) with a copy button - it previously listed grants without ever saying how to create one. The URL is built from window.location.origin, not a configured BASE_URL: whatever host the admin reached the page on is by definition resolvable, whereas a configured value can be stale or internal. Copy failure is surfaced explicitly, because navigator.clipboard is unavailable outside a secure context and a self-hosted instance on plain http is exactly that case.

MCP surface — design decision (2026-06-22). The MCP intentionally exposes only the booking lifecycle (discover → slots → book → view/list/reschedule/cancel), not Calnode's full ~88-endpoint surface. Event-type CRUD, availability rules/overrides, members/teams, calendar connections, webhooks, branding/email/tracking settings, and key/connection management are deliberately not exposed. Rationale: every tool we open is additional attack surface and agent-confusion surface, and must be kept role-safe across transports — so we don't open capabilities just because we can. We will revisit and reassess in the near future once there's evidence of demand; the likely first expansion (per the PRD's AI roadmap) is availability (read + natural-language set), then event-type creation. When we do expand, prefer narrow, purpose-built tools over a generic "call any endpoint" tool.

Roles & permissions — design decision (2026-06-22). Roles are fixed (owner / admin / member) with hard-coded capabilities, enforced identically across the REST API, the admin UI, and the MCP tools. Configurable RBAC (a per-role permission matrix the owner edits) was deliberately deferred — it's the industry norm to ship fixed roles, the PRD scopes "advanced RBAC" to a later paid add-on, and a matrix would multiply the enforcement/test surface across three call sites (a misconfiguration = privilege escalation). The role-scoping is forward-compatible: tools compute "can this user do X" from the bound caller's role (mcpCallerScope), so a future config layer would change only how that's computed, not the call sites. If a real need appears, prefer a few targeted toggles (e.g. "members may connect agents", "members see team-wide bookings") over a general matrix.


20. LLM layer (conversational booking)

Optional, off by default, configured like SMTP (PRD §8.11). The core stays LLM-free; AI is purely additive and degrades gracefully — if it's off or unreachable, every surface falls back to the deterministic slot picker.

  • Provider config (internal/llm, llm_settings.go, migrations 00034/00035): a dependency-free client for any OpenAI-compatible chat-completions endpoint — {endpoint, model, api_key} stored on server_settings (key encrypted), set in Settings → AI with a test-connection ping. Provider/model are config, not code. Handler.getLLM() returns nil when off. Verified live with MiniMax M3.
  • Conversational booking (booking_assistant.go): POST /v1/event-types/{slug}/assistant runs an LLM tool-loop scoped to one event type — the model drives find_available_slots (→ computeSlots) and book (→ createBookingForSlug, the same creation core as MCP/REST). Key invariants: the LLM does NL → constraints + ranking, never time arithmetic (the deterministic engine computes availability); it never sees raw calendar data — only computed windows + public config (privacy by construction); name/email + a booker confirm are required before book commits. Reasoning models' inline <think> is stripped; the prompt enforces brevity. Public + anonymous, so it's rate-limited (15/min/IP) with conversation/iteration/token caps.
  • Streaming: replies stream token-by-token. llm.ChatStream parses the provider's SSE; the endpoint content-negotiates — Accept: text/event-stream runs the tool-loop with ChatStream and emits {token|status|done|fallback} SSE events (<think> stripped on the fly), while other callers keep the one-shot JSON path. (Gotcha: the request-logging responseWriter embeds the RW interface, which doesn't promote Flush — it has an explicit flush-transparent Flush() so streamed responses aren't buffered.)
  • Admin instructions: the base prompt (assistantBaseRules) is code-owned (the tool-calling contract + safety) and not editable; admins append free-text "Additional instructions" (tone/business context) shown alongside a read-only view of the base.
  • UI surfaces: a floating launcher → drawer plus a subtle inline "Book by chat" link on the booking page (book.html), and the inline link only on the embed widget (embed.js) — no global floating button there, to avoid colliding with the host site's own widgets. Shared .asst-* styles live in booking.css; /booking.css is content-hash cache-busted (?v=) on the page <link> tags so CSS edits ship without a stale-cache wait. The manage page is deliberately excluded — it's reschedule/cancel context, and conversational AI's payoff is first-booking acquisition (a reschedule chat is deferred until there's demand evidence).

21. Payments (Stripe) — paid bookings

Optional, off by default, configured in Settings → Payments (one Stripe account per instance — instance-per-tenant, so no Connect/marketplace logic; the workspace keeps 100%). internal/stripe is a dependency-free client: Checkout Session create, session fetch, refund, and webhook signature verification (HMAC-SHA256 over t.payload, constant-time, 5-min tolerance).

  • Price is per event type (price_cents + currency; 0 = free → today's flow untouched). A price can't be saved unless Stripe is configured.
  • Pay-then-book with a held slot. The booking is created as a normal confirmed row so the existing double-booking guard reserves the slot, but with payment_status='pending' and all confirmation side-effects deferred. (A separate payment_status column avoids a SQLite rebuild of the bookings.status CHECK.) The booker is redirected to Stripe Checkout.
  • Webhook confirms. POST /v1/stripe/webhook (public, signature-authenticated) handles checkout.session.completedconfirmPaidBooking flips pending→paid (idempotent via a conditional UPDATE), records the charged amount, and dispatches side-effects in the background (acks in <200ms, well under Stripe's retry window). checkout.session.expired (+ a 45-min worker backstop) releases unpaid holds, freeing the slot.
  • Refund on cancel for paid bookings — fires from the shared cancelSideEffects, so admin, manage-link, and MCP cancels all refund.
  • Surfaced everywhere: payment_status/amount_paid_cents/amount_paid_currency ride on booking.Booking → REST (GET single + list), MCP (get_booking/list_bookings), and the webhook payload (default fields, omitempty so free bookings are unchanged); a $ chip in the admin bookings list; price on the booking page + embed; and value/currency/is_paid/ transaction_id in the dataLayer conversion event (free + paid paths). Agents/assistant can't pay, so paid events are rejected on the shared booking core (Checkout is booking-page only).

22. Built-in video meetings (LiveKit)

Self-hostable video as a booking location type (event_types.location_type = "livekit"), backed by a LiveKit server (Cloud or self-hosted — same config in Settings → Video). Deliberately SDK-free server-side: all tokens are hand-signed HS256/HMAC, so there's no Go SDK to vendor. The browser room is vanilla JS + a vendored client SDK (assets/livekit-client.umd.min.js), not part of the Svelte SPA.

Pieces. internal/livekit/livekit.go (token signing/verifying), admin.go (Twirp admin API + Egress, DeleteRoom/UpdateParticipant/ListParticipants/UpdateRoomMetadata/egress). internal/handler/livekit_room.go (token exchange, host auth, host-control endpoints) + livekit_recording.go (record start/stop, recordings list/download, webhook sink). Room UI = templates/livekit-room.html + assets/livekit-room.js (both go:embed-ed in the handler pkg — a Go rebuild is needed after editing them, independent of the SPA build).

Three token kinds (do not conflate):

  1. Room token — opaque HMAC blob ({r:room, e:exp, role}) embedded in the booking's join URL. No LiveKit grant, so the API secret never ships to the browser and the link can't be replayed past the booking window. Signed/verified with the per-instance roomKey.
  2. Access token — the real LiveKit HS256 JWT (video grants) the SDK joins with; minted on demand by the token-exchange endpoint. VerifyAccessToken re-checks it server-side for the host model below.
  3. Admin token — short-lived HS256 JWT (room-scoped or server-level grant) for Twirp calls.

Host authority — authorizeHost, the key invariant. Two booking links exist (attendee + host). A host action is authorized if the caller is either:

  • the durable host (hostRoomOrOwner) — holds a host room token, OR is the signed-in booking owner (so an owner who opened the attendee link still drives the meeting); or
  • the current reassigned host — proven by verifying their access token (VerifyAccessToken) and confirming that identity currently has metadata="host" via ListParticipants.

Clients therefore send both t (room token) and at (access token) on every host call. Reassignment only flips a participant's metadata; without the access-token path a temp host would have the badge but no server-side power — authorizeHost is what closes that. Reclaim host is durable-host-only (a temp host can never reclaim). Single host: any host join calls demoteOtherHosts (metadata→"attendee"); clients downgrade only on the explicit "attendee", never on a transient/empty metadata event.

Recording (Egress). Room-composite egress → the Litestream backups bucket (LITESTREAM_* env), recordings/ prefix; rows in a recordings table (status activecomplete/failed, object_key set at start). Finalization does not depend on the webhook: finalizeActiveRecording stops the egress and marks the row complete on RecordStop / EndRoom / host-leave; a startup sweep closes orphaned active rows (so a stuck row can't block re-recording in a reused room). Downloads are presigned SigV4 GETs (s3presign.go). The webhook only enriches (accurate duration, file-ready confirmation).

Webhook — single sink. LiveKit allows one webhook URL per project, so POST /v1/livekit/webhook (LiveKitWebhook; legacy alias /v1/livekit/egress-webhook) receives every project event. Each is signature-verified (VerifyWebhook, API key/secret); we act only on egress_started/ended/failed (banner flag + finalize row) and room_finished (stop a straggling egress), and 200-ACK + drop the rest (room_started, participant_*, track_*, …). The egress lifecycle is the source of truth for the recording banner flag, so the indicator self-heals regardless of which path started/stopped it. Lifecycle events (attendance, duration) are received but not yet persisted — see PRD Phase 4.

Shared room state = metadata. Room metadata is one JSON blob {recording, allowShare}, pushed to all clients via LiveKit's RoomMetadataChanged. Always mergeRoomMeta (read-merge-write) — overwriting would clobber the other flag. The recording banner is an in-room overlay, hidden by showOnly off the room view. Attendee screen-share defaults off, enforced via canPublishSources (token mint + live UpdateParticipant), not just UI-hidden.

Deferred: notetaker/transcription (calnode-notetaker agent dispatch → transcript callback → LLM summary) — the next build; consent-gated (§8.11/§15 of the PRD).


23. Languages (i18n)

Calnode ships 8 locales: en (source) · es · fr · de · it · pt · nl · sv.

What is translated, and what is not

Translation covers the surfaces a booker sees. It deliberately stops there.

Surface Translated?
Booking page (book.html) yes
Manage page (manage.html) - reschedule/cancel yes
Embed widget (embed.js) yes
The four emails (confirm / cancel / reschedule / reminder) yes
Calendar invite title + description yes
Conversational booking assistant yes (replies in the visitor's language)
Admin SPA (frontend/) no - English only, by design
LiveKit room (livekit-room.html + livekit-room.js) no - see below
Admin-authored content (event names, descriptions, intake questions, custom email copy) no - stored as the operator typed it
IANA timezone identifiers no - shown as Europe/Berlin, not localized

The LiveKit room is the one real hole. ~45 strings in the in-browser meeting UI (join/leave, mute, screen share, recording banner, consent prompts, chat) are hardcoded English. It was excluded because the room is vanilla JS with its own asset pipeline (go:embed-ed in the handler package, not the SPA build) and shares no template or string plumbing with the booking surfaces, so it needs its own small runtime t() rather than a reuse of {{call .T}}. Not hard; just genuinely separate work. Tracked in §18.

Admin-authored content is a deliberate non-goal, not an oversight: an operator serving a German market writes their event descriptions in German, and a German visitor then gets a coherent page. The mismatch only shows for an operator serving several languages at once, which per-locale content overrides would solve if it is ever asked for.

How a locale is resolved

Per request, in internal/handler/i18n.go, highest priority first:

  1. ?lang=<code> override (the footer language switcher sets this)
  2. Accept-Language, negotiated with golang.org/x/text/language's matcher - so de-AT resolves to de, and a list like ja;q=0.9,es;q=0.5 falls through to Spanish rather than giving up
  3. The operator's fallback language setting (Settings → Branding), used only when nothing matched. confidence == language.No is what separates "no match" from a weak-but-real match; a real match is never overridden by the fallback
  4. English

The booker's resolved locale is persisted on the booking (booking_attendees.locale, migration 00051) so that later emails - reminders, cancellations, the reschedule notice - arrive in the language they booked in, not the language of whoever triggered the send. Host-facing sends deliberately pass a nil locale and stay English.

Pages that vary on this send Vary: Accept-Language, Cookie; the public event-type JSON endpoint adds it with Header().Add so it does not clobber the CORS middleware's Vary: Origin.

Adding a language is adding one file

internal/i18n/locales/<code>.json. That is the whole change - no Go, no template, no frontend edit. init() globs the directory, and the language switcher, the fallback-language dropdown, and the public API payload all read SupportedLocales(). This was verified by adding six locales at once without touching code.

Go's stdlib has no locale data, so anything a time format string would normally give you is a key in the JSON instead (internal/i18n/datetime.go): date_format, clock_format, dow_short_*, month_short_*. That also makes per-language date shape expressible - German carries its own date_format for the ordinal period ("Mo, 22. Jun 2026").

Three guards police a new file (go test ./internal/i18n/):

  • TestAllLocalesHaveTheSameKeys - no missing or unknown keys.
  • TestAllLocalesHaveMatchingFormatVerbs - uses fmt itself as the oracle, so a locale that drops or reorders a %s/%d fails the test instead of emitting %!(EXTRA…) to a booker. An earlier hand-rolled parser for this was unsound (it missed indexed verbs like %+[2]d); do not reintroduce one.
  • TestDateTablesMatchCLDR - shells out to node and compares every dow_short_*, month_short_* and clock_format against Intl, skipping if node is absent. This is the only check that can tell dim. from dim, or catch a 12h/24h call that is wrong for the language. It caught a real drift on first run (es had sep where CLDR says sept).

Two traps:

  • Filenames must be BCP-47 canonical. pt-br.json is wrong - language.Make("pt-br") canonicalizes to pt-BR, and the mismatch used to return a nil *Locale for an exact match. Fixed and regression-tested, but name the file correctly anyway.
  • Tests use ja/ko to mean "a language we do not ship." If you ever add one, assertUnsupported / requireUnsupported fail loudly and name the fixture to change. That guard exists because fr/de used to play that role and silently became wrong the day those languages shipped.

Translation quality

Every non-English locale is an LLM draft with no native review. The structure is verified by the guards above; the wording is not. Register was chosen per language rather than uniformly (fr/de/it/pt formal, es/nl/sv informal), matching what business booking pages do in each market, but that too is unreviewed. Corrections via PR are welcome and are the expected path to improving them. Do not describe a language as production-quality in marketing copy without a speaker reading the booking page, the manage page, and the four emails end to end.

Known limitations

  • Plurals are 2-form only (duration_hour_one / duration_hours). Languages with richer CLDR plural categories (Polish, Russian, Arabic) need a real plural-rule selector before they can ship correctly.
  • clock_format is a boolean, not a pattern, so "14:30 Uhr" or "14時30分" are not expressible - only 12h vs 24h.
  • No RTL layout support (declared out of scope).
  • No automated visual harness for the Go-template surfaces, so per-language layout checks (long German compounds overflowing buttons) are manual, desktop and mobile.

24. Changelog

This doc tracks the code; when you change behaviour in an area above, update the matching section in the same PR. Notable rounds:

  • 2026-08-20 — Multi-language public surfaces (8 locales). Booking page, manage page, embed widget, the four emails, the calendar invite and the booking assistant now render in en es fr de it pt nl sv (new §23). Locale is negotiated from Accept-Language with a ?lang= switcher and an operator fallback setting, and is stored on the booking (migration 00051) so reminders match the language the booker used. Adding a language is adding one JSON file - proven by adding six at once with no code change. Go has no locale date data, so the date tables are keys in the JSON, cross-checked against Intl/CLDR by a node-backed test. Not translated: the admin SPA and the LiveKit room (§18).

  • 2026-06-30 — Cookie consent + legal links + shared book/manage partials. Native GA4/GTM (Settings → Tracking) is now consent-gated on book.html and manage.html: nothing loads, no _ga* cookies are set, until the visitor Accepts a banner (180-day calnode_consent cookie; footer "Cookie settings" reopens it; Decline clears _ga*). Operator Privacy/Terms URLs (Settings → Branding → Legal links, migration 00046) render in both pages' footers and from the banner. The operator-injected Head HTML path stays ungated (trusted code, unchanged); the embed widget has neither — it runs inside the customer's own site, which owns its own analytics/consent. Landed alongside a shared-partials refactor: internal/handler/templates/_shared.html ({{define}}s parsed into BOTH the book and manage template sets) now holds the consent/tracking/footer chrome, the calendarGrid partial (month-nav + day grid), and eventMeta (duration/location/ price) — one source instead of copy-pasted markup. A cross-surface contract test (booking_surfaces_contract_test.go) additionally pins the structural hooks shared with embed.js (a separate vanilla-JS web component the Go partials can't reach) so CI fails if the three booking surfaces drift apart.

  • 2026-06-25/26 — Built-in video (LiveKit). Full subsystem (see §22): SDK-free hand-signed tokens, vanilla-JS room app, host controls (end-for-all, single-host reassign/reclaim, attendee screen-share toggle), recording (Egress→S3, finalize-on-stop + startup sweep, presigned downloads), single signature-verified webhook sink driving a self-healing recording banner.

  • 2026-06-23 — Zoom + Stripe paid bookings. Per-host Zoom OAuth (meeting links minted under the assigned host's account; a meeting-link provider, independent of the calendar). Stripe pay-then-book (held slot → Checkout → webhook-confirm → refund-on-cancel); payment is first-class across REST/MCP/webhooks/tracking. New §21.

  • 2026-06-22 — LLM layer (conversational booking). Optional BYO-LLM (OpenAI-compatible, off by default; Settings → AI). Public …/assistant tool-loop over the deterministic cores (no raw calendar data; LLM doesn't do time math); floating drawer + inline link on book.html, inline link on the embed widget; admin "Additional instructions" over a code-owned base prompt; <think> strip; booking.css content-hash cache-busting. Manage page intentionally excluded. New §20.

  • 2026-06-22 — Native MCP server + OAuth "Connect". A Model Context Protocol server compiled into the binary (official Go SDK) with 10 tools over stdio (calnode mcp) and Streamable HTTP (/mcp) — the 8 booking-lifecycle tools plus get_meeting_notes/get_transcript (notetaker outputs, host-scoped read-only); tools reuse the REST services via extracted shared cores (no parallel code path). Calnode is its own OAuth 2.1 AS (discovery + DCR + PKCE auth-code/refresh + consent reusing the existing SSO login; migration 00033) so agents connect by URL + click Connect; a cno_ API key still works. Tools are role-scoped (members → own bookings only). Connected apps admin page (/connections) to revoke grants. Fixed roles kept; configurable RBAC deferred (see the design note above). New §19. Fixes this round: RFC 9207 iss on the auth response, and consent-page form-action CSP must allow the client redirect origin (else the post-consent redirect to the client is blocked).

  • 2026-06-21 — Microsoft 365 + multi-provider calendar. A calendar provider abstraction (internal/calendar Provider + Service), the Microsoft 365 / Outlook provider (Graph free/busy, create/reschedule/cancel, Teams links), multi-tenant + personal Microsoft support (MICROSOFT_TENANT=common), work/personal account-kind detection (id_token tid, migration 00032), provider-matched meeting-link generation with manual fallback, save-time location validation for every type + smart default + picker reorder, and Microsoft OAuth sign-in (/v1/auth/microsoft/*, identity-only). Touched §3, §4, §6, §10. Known constraint: Teams auto-links need a work account (§18).