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.
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).
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.
- 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_*andGOOGLE_CLIENT_ID/SECRET(also settable at runtime in DB settings, which take priority)MICROSOFT_CLIENT_ID/SECRETandMICROSOFT_TENANT(defaultcommon; use the multi-tenantcommonso 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 onecalendar.Serviceand 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 + adrainfunc. - Ops endpoints:
GET /healthz,GET /readyz(readiness gate),GET /version(build stamp frominternal/buildinfo). - Graceful shutdown drains the worker and in-flight requests before
db.Close().
internal/db: opens SQLite withSetMaxOpenConns(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 COLUMNis reversible-by-convention only (SQLite can't easily drop columns).
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.
- Identity/authz:
users(+is_admin,is_owner,archived_at,archived_by, plus prefstime_format/week_start/date_format, sevennotify_*toggles, and auth columnsemail_login/password_hash/provider/provider_id),sessions(cookie auth),api_keys(SHA-256 hashed),invite_tokens(hashed, single-use). There is noauth_providerstable — OAuth identity is columns onusers(migration 00012). - Teams:
teams,team_members(withrouting_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-EThours_before, UNIQUE). - Availability:
availability_rules(weekly),availability_overrides(dated). - Bookings:
bookings(primaryhost_id,external_event_id, status),booking_hosts(every attending host +is_primary+ per-hostexternal_event_id),booking_attendees(organizer + invitees),booking_answers,booking_manage_tokens(PK is the hashed token; TTL is app-level, set byIssueManageToken, not a schema constraint). Note:bookings.host_idandbooking_hosts.user_idcarry no ON DELETE clause (NO ACTION), andusers.archived_byis 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_conflictsroles, andaccount_kindwork|personal from migration 00032),server_settings(a singleton row id=1 holding SMTP + Google creds — encryptedsmtp_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.
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 eightserver_settingscolumns: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_encacrossinternal/db/migrations/*.sqlfor 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 underCALNODE_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_keyre-wraps the same DEK under a new platform secret (it does not rotate the DEK or re-encrypt columns);recover_keyunwraps via the recovery escrow;reset_adminis a bcrypt password reset (not part of the crypto subsystem). - Dev caveat: with no
CALNODE_ENCRYPTION_KEYand a non-https BASE_URL, the vault generates an ephemeral per-process DEK (so any persisted*_encdata 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.
- API keys (
cno_…, SHA-256-hashed inapi_keys) and browser sessions (cookiecalnode_session, HttpOnly/SameSite=Lax/Secure-when-https, 30-day, stored insessions) both satisfyRequireAuth(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 (Googleopenid email profile; Microsoftopenid email profile User.Read— calendar access is a separate connection with its own scopes), share helpers inauth_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 aSameOriginCheckmiddleware (internal/server) that rejects a state-changing request whose Origin/Referer host ≠ the request Host — but only when thecalnode_sessioncookie 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_owneris additive overis_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.
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.
- 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, injectableNow. - Computes per-host free windows per UTC day, intersects/subtracts busy, aligns to
the slot interval, then
pickHostsdecides 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_minutesis how often a booking may start;duration_minutesis 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.Generaterejects a non-positive interval, so both create and update validate it; a0would 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 ahostsmetadata map (id→name/avatar) for rendering faces. - Busy for a host = every non-cancelled booking they attend, via
booking_hosts(NOT justbookings.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-emptybooking_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).
- 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.
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.
/slotsis 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.GenerateWithTakenwalks the range twice - once normally, once with every host'sBusyignored - 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.
computeSlotstakes an explicitincludeTaken, and MCPget_available_slotsand 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. takenis absent, not empty, when off - a client must distinguish "does not show taken times" from "opted in, nothing booked today".- Client side:
mergeDaySlotsandbookableDayKeysin the sharedassets/booking-logic.js, so all three surfaces share one implementation. Free and taken stay separate on the wire and are combined only for display.bookableDayKeysexists 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.
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); insertbookings(host_id = primary) + abooking_hostsrow per attendee (is_primaryflag) + 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 (storeexternal_event_idper host), per-host confirmation emails (by prefs), attendee confirmation, webhook, reminders. Idempotency (opt-in): anIdempotency-Keyheader 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 (ViewerIDpins 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/bookingsand MCPlist_bookingsboth 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
INclause 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
bookingslead onhost_idand are partial, so a listing planned asSCAN ... 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 theLIMIT.Countsis an aggregate over the match set and still scans; that is inherent, and it replaced a scan that also materialised every row. Countsis a separate query and deliberately ignoresWhen/Limit/Offset, so the Upcoming/Past tab labels describe the whole match set rather than the page. Deriving them fromlen(items)is the obvious mistake once paging exists.- An explicit
statusreplaces the default cancelled-exclusion rather than filtering after it. Both list paths previously hardcodedstatus != 'cancelled'and filtered on top, sostatus=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 populatingevent_type_hosts. A member of two teams therefore appears under both.
- all in SQL.
- Public-surface abuse controls: the
/slotsendpoint is rate-limited (60/min/IP,slotsRL) andPOST /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) andCancelByToken(manage link) shareHandler.cancelSideEffects— loopsbooking_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 clearsexternal_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) +RescheduleByTokensharemoveCalendarEvents—Service.UpdateEventmoves every assigned host's event to the new time. The overlap re-check covers all the booking's hosts, not just the primary. - Reassign:
ReassignHostswaps the primary host (archive/resolve flow), moves the calendar event old→new, and keepsbooking_hostsin 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.
Calnode talks to calendars through a provider abstraction, not a single vendor:
internal/calendardefines theProviderinterface (Name, InvitesGuests, AuthURL/EncryptState/Exchange, Connected/Disconnect/HasDestination, FreeBusy, CreateEvent/UpdateEvent/CancelEvent) and aServicethat dispatches per-user: it readscalendar_connections.providerfor 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) andinternal/calendar/microsoft(Microsoft 365 / Outlook via Graph). Both implementProvider. OneServiceis built at startup (internal/server) and each configured backend isRegistered. - 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_connectionsholds per-user OAuth tokens (encrypted), theprovider, connection rolesis_destination/check_conflicts, andaccount_kind(migration 00032 —work|personal|""). Token refresh is automatic via an oauth2TokenSourcewrapped by asavingTokenSourcethat persists refreshed tokens (and preservesaccount_kind, which a refresh has no id_token to re-derive).
Per-provider notes.
- Google (
internal/gcal): Meet viaconferenceData.createRequest+conferenceDataVersion=1(returnshangoutLink);CancelEventtreats 410 Gone as success;sendUpdates=all. Free/busy via the freebusy API. - Microsoft (
internal/calendar/microsoft): Graph/me/events; Teams viaisOnlineMeeting:true, onlineMeetingProvider:"teamsForBusiness"→onlineMeeting.joinUrl; free/busy via/me/calendarView.account_kindis captured at connect from the id_tokentidclaim (the consumers tenant9188040d-…⇒personal, elsework) — added via theopenidscope. A blanket404 MailboxNotEnabledForRESTAPIon 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 onbookings.external_event_idfor 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.
Two layers, and they are easy to confuse:
calendar_connections.is_destinationpicks the account.connection_calendars.is_destinationpicks 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.
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.HasDestinationavoids pointless retries for hosts with no calendar. - Time-drift (reschedule): a failed inline move (
UpdateEvent) flags the host rowneeds_sync(migration 00025);reconcileReschedulesre-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).
-
internal/mailer: two transports behind oneMailerinterface. TheFromheader ={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) selectsresend.go, which posts toapi.resend.comover 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/emailreturns the livetransport, and the admin UI badges it, so filled-in SMTP fields are never mistaken for SMTP delivery. - a Resend API key set (
-
The dial must stay bounded.
defaultSMTPTimeoutonce applied only viaconn.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). -
ErrUnreachabledistinguishes "could not connect at all" from "connected, then rejected", soPOST /v1/settings/email/testcan name the platform-block possibility instead of reporting a generic failure. Both transports use it. -
Calendar invites over the API path: the
.icsis attached with its full MIME type,text/calendar; charset=utf-8; method=REQUEST. Themethodparameter is what makes a client render an RSVP-able event rather than a file. Resend'scontent_typefield is set explicitly for this and pinned byTestICSAttachmentKeepsItsMethodParameter, 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 sentmultipart/alternative— a styled HTML body plus the plain-text version as the fallback. One sharedhtml/templatelayout (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 setHideManageLink.RenderBodyreturns 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.icsinvite (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 viaService.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).icswould duplicate it; when there's no destination, the.icsis how that recipient gets the meeting on their calendar. Stable UID{bookingID}@calnode+ non-decreasingSEQUENCE(updated_at unix) lets a client match the REQUEST → reschedule → CANCEL to one event.smtp.go: buildRawnests the body inmultipart/mixedwhen a message has attachments; the body itself ismultipart/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-widebusiness_name+logo_url+banner_urlon 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/logoand.../banner, public serve atGET /branding/logo/GET /branding/banner) reusing the avatar pipeline:imaging.Fitinto 600x200 (logo) or 1600x800 (banner) preserving aspect ratio (no crop), re-encoded PNG (keeps transparency), stored on the/datavolume. Both URLs store the relative serve path with a?v=<ts>cache-buster;Handler.applyBrandingmakes 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 onbook.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 CSPimg-srcallowshttps:/data:so the logo/banner load (strictPublicCSP). Brand is threaded into every send site (booking/cancel/reschedule/reassign + worker reminder). -
Reminders: scheduled as
jobsand sent by the worker (§13). -
Deliverability note (ops): prod sends via Resend SMTP (
smtp.resend.com, userresend, password = API key, STARTTLS);orchestratr.aiverified so any@orchestratr.aiFrom 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.
internal/worker: polls thejobstable every 5s (batch ≤10). Job types:webhook.deliverandreminder.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_attempts3. Atomic claim viaUPDATE … WHERE status='pending'+ RowsAffected.internal/webhook: enqueuesbooking.created/.cancelled/.rescheduledplus the notetaker eventsrecording.completed/transcript.ready/notes.ready(reference payloads — booking-shaped, keyed by id; consumers fetch the artifact body via REST/MCP). There is nobooking.reminderwebhook event. Deliveries are signed HMAC-SHA256, headerX-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
dataobject (webhooks.fieldsJSON, 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 owndata. 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.
- Members see only bookings they host —
ListByHostmatchesbookings.host_idOR anybooking_hostsseat, 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 onIsAdmin; ignored for non-admins so it can't escalate). The bookings page has a "My / All" toggle for admins with a Host column.
- 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; memorytailwind-merge-version).pnpmonly;pnpm execfor local bins (pnpm dlxhas a Windows manifest bug). - shadcn states styled via Tailwind
data-*variants need@custom-variantremaps inapp.cssor they render silently unstyled — runpnpm test:visualafter touchingui/**,app.css, or the theme (memoryshadcn-tailwind-variants). - Prefer a component's variant prop over a
classoverride of its size default (e.g. button height) — overrides rely on tailwind-merge stripping the default. - Control heights are compact
h-8by default ("nova"); auth-screen CTAs use explicith-11.
- 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_URLsplit, version stamp, readiness gate. Custom domains: a tenant pointsbook.acme.comat their instance;PUBLIC_BASE_URLdrives booker-facing links/emails whileBASE_URLstays the identity host for OAuth/admin. Managed SaaS provisioning is later/lower priority. - Behind a reverse proxy (Fly / Railway / nginx) — required: forward the
original
Hostheader. The CSRF same-origin check (§6) compares the request'sOrigin/RefereragainstHost, so a proxy that rewrites Host would false-block admin writes (403). Fly and Railway preserve Host by default; a hand-rolled nginx needsproxy_set_header Host $host;. Related: per-IP rate limits (§8) key on the TCP remote address (proxy headers likeX-Forwarded-Forare 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.
- SQLite single connection — never query inside an open cursor; materialize first (§4).
- All times UTC in storage; convert at the edges. The slot busy-window must be widened for tz boundaries.
- Frontend is embedded at compile time —
pnpm build+ rebuild Go to see changes. - Two public templates drift — keep
book.htmlandmanage.htmlin sync. - tailwind-merge must track Tailwind major (§15).
- Calendar side effects are best-effort — the reconciler is the safety net; keep ops idempotent.
- Booking visibility / availability key on
booking_hosts, nothost_idalone, or multi-host attendees become invisible / double-bookable. - Public-page CSP is dynamic —
publicCSP()(tracking_settings.go) returns the strict default and relaxes only when head code-injection is configured (broadhttps:or the operator'stracking_csp_allow). Don't re-hardcode the CSP on thebook/managehandlers — route it throughpublicCSP.
- 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 accounts —
teamsForBusinessis 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-8shell 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.
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 mcpsubcommand (cmd/calnode/mcp.go) boots the full stack viaserver.BuildHandler(the service-wiring half ofserver.New, factored out so both paths share it) and runs overmcp.StdioTransport. Logs go to stderr — stdout is the JSON-RPC stream. - Streamable HTTP — mounted at
POST /mcpinserver.Newviamcp.NewStreamableHTTPHandler, wrapped inRequireAuth(API-key path is the intended caller; the session path stays same-origin-guarded). No SSE.
- stdio — the
- No parallel code path. Tools call the same internal services as the REST
handlers. The shared cores:
computeSlots(slot generation, also behindGetSlots),validateAnswersCore(intake-answer validation, behindvalidateAnswers),resolveBookingHostPool(routing-mode host split), and the side-effect dispatchersdispatchBookingConfirmation/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(afterRequireBearerToken) binds the user+role to the request context, and the tools branch on it: members see/act on only bookings they host (ListByHost, host-scopedCancel), 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_bookingare the public booking surface (unscoped). Tools translate between the slug they expose asevent_type_idand the internal booking id. Gap:create_bookingdoes not yet honour anidempotency_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/mcpresource, so an agent (Claude, ChatGPT) adds the server by URL and clicks Connect instead of pasting a key./mcpis guarded byauth.RequireBearerToken(VerifyMCPBearer); a401advertises/.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/authorizechecks for a Calnode session — if absent it bounces through the existing Google/Microsoft login (via apost_login_redirectcookie thatfinishOAuthLoginhonours) and back to a consent screen.POST /oauth/tokendoes PKCE-S256-verifiedauthorization_codeand rotatingrefresh_tokengrants. - Tokens are opaque, SHA-256-hashed in
oauth_access_tokens(migration 00033);VerifyMCPBeareraccepts either an OAuth access token or acno_API key, so scripted callers keep working. The worker purges expiredoauth_auth_codes. - The slick Connect UX needs the server on HTTPS with valid metadata (deployed
instance);
http://localhostworks 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/mcpaccess). 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 fromwindow.location.origin, not a configuredBASE_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, becausenavigator.clipboardis 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.
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 onserver_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}/assistantruns an LLM tool-loop scoped to one event type — the model drivesfind_available_slots(→computeSlots) andbook(→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 beforebookcommits. 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.ChatStreamparses the provider's SSE; the endpoint content-negotiates —Accept: text/event-streamruns 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-loggingresponseWriterembeds the RW interface, which doesn't promoteFlush— it has an explicit flush-transparentFlush()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 inbooking.css;/booking.cssis 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).
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
confirmedrow so the existing double-booking guard reserves the slot, but withpayment_status='pending'and all confirmation side-effects deferred. (A separatepayment_statuscolumn avoids a SQLite rebuild of thebookings.statusCHECK.) The booker is redirected to Stripe Checkout. - Webhook confirms.
POST /v1/stripe/webhook(public, signature-authenticated) handlescheckout.session.completed→confirmPaidBookingflipspending→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_currencyride onbooking.Booking→ REST (GETsingle + 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; andvalue/currency/is_paid/transaction_idin 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).
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):
- 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-instanceroomKey. - Access token — the real LiveKit HS256 JWT (video grants) the SDK joins with; minted on
demand by the token-exchange endpoint.
VerifyAccessTokenre-checks it server-side for the host model below. - 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 hasmetadata="host"viaListParticipants.
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 active→complete/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).
Calnode ships 8 locales: en (source) · es · fr · de · it · pt · nl · sv.
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.
Per request, in internal/handler/i18n.go, highest priority first:
?lang=<code>override (the footer language switcher sets this)Accept-Language, negotiated withgolang.org/x/text/language's matcher - sode-ATresolves tode, and a list likeja;q=0.9,es;q=0.5falls through to Spanish rather than giving up- The operator's fallback language setting (Settings → Branding), used only when
nothing matched.
confidence == language.Nois what separates "no match" from a weak-but-real match; a real match is never overridden by the fallback - 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.
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- usesfmtitself as the oracle, so a locale that drops or reorders a%s/%dfails 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 tonodeand compares everydow_short_*,month_short_*andclock_formatagainstIntl, skipping if node is absent. This is the only check that can telldim.fromdim, or catch a 12h/24h call that is wrong for the language. It caught a real drift on first run (eshadsepwhere CLDR sayssept).
Two traps:
- Filenames must be BCP-47 canonical.
pt-br.jsonis wrong -language.Make("pt-br")canonicalizes topt-BR, and the mismatch used to return a nil*Localefor an exact match. Fixed and regression-tested, but name the file correctly anyway. - Tests use
ja/koto mean "a language we do not ship." If you ever add one,assertUnsupported/requireUnsupportedfail loudly and name the fixture to change. That guard exists becausefr/deused to play that role and silently became wrong the day those languages shipped.
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.
- 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_formatis 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.
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 fromAccept-Languagewith 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 againstIntl/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-daycalnode_consentcookie; 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, thecalendarGridpartial (month-nav + day grid), andeventMeta(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
…/assistanttool-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 plusget_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; acno_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 9207isson the auth response, and consent-pageform-actionCSP 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/calendarProvider+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_tokentid, 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).