Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
f685ced
db: dialect-aware Open plus a rebinding DB/Tx wrapper
distronode-com Sep 4, 2026
dead65e
db: PostgreSQL migration set, generated once from the SQLite one
distronode-com Sep 4, 2026
65099c3
db: compare the migrated schemas across both engines
distronode-com Sep 4, 2026
dfd7bd7
db: thread *db.DB / *db.Tx through every call site
distronode-com Sep 4, 2026
62dc81e
db: port the SQL that only SQLite accepts
distronode-com Sep 4, 2026
e675520
booking: hold an advisory lock on the host across the overlap check
distronode-com Sep 4, 2026
c1478c1
test,docs,ci: run the suite against PostgreSQL
distronode-com Sep 4, 2026
78dab82
db: classify constraint violations by code, not by English message
distronode-com Sep 4, 2026
4b86071
db: the last four things only SQLite accepted
distronode-com Sep 4, 2026
9c7792e
db: classify constraint violations by SQLite's codes too, not its prose
distronode-com Sep 4, 2026
26f17b6
docs: record the SQLite error-code correction on the branch log
distronode-com Sep 4, 2026
cf972cf
db: pin every TEXT timestamp column to COLLATE "C"
distronode-com Sep 6, 2026
a1aa94b
handler: verify RETURNING position on both engines
distronode-com Sep 6, 2026
dce3568
db: delete the bare Open, readiness goes through the handle
distronode-com Sep 6, 2026
5297bbf
db,config: make the Postgres pool size configurable
distronode-com Sep 6, 2026
bfa2bca
auth: sign someone in from an identity system you already run
distronode-com Sep 6, 2026
82bce26
auth: sign out everywhere, including the MCP connectors
distronode-com Sep 6, 2026
d18bfab
docs: record Boundary 7 on the branch log
distronode-com Sep 6, 2026
6e7f839
server: let an operator name the proxies whose client IP is real
distronode-com Sep 6, 2026
ee6eefb
server: let an operator frame the admin console, and nothing else
distronode-com Sep 6, 2026
22dad87
metrics: a Prometheus endpoint that is not public by accident
distronode-com Sep 6, 2026
f6a7ffd
stt: let an operator choose which endpoint hears the recordings
distronode-com Sep 6, 2026
a7500b9
webhook: tell a subscriber when a reminder went out
distronode-com Sep 6, 2026
0bf2c9a
i18n: ship Canadian French as its own locale, not as a fallback
distronode-com Sep 6, 2026
5c00506
test: move knownMigrationCount with the migration this branch adds
distronode-com Sep 6, 2026
ae3cb5e
db,config: the tenant root, the workspace column, and the policies
distronode-com Sep 6, 2026
dc029b7
db: two handles, and a tenant bound per statement rather than per con…
distronode-com Sep 6, 2026
ec65542
handler: split the process state off the request, and resolve a tenan…
distronode-com Sep 6, 2026
8dbab4a
server,handler: every route says which tenant it resolves, and how
distronode-com Sep 6, 2026
1566fef
server: prove the tenancy end to end, through the real mux and a real…
distronode-com Sep 6, 2026
67e8ec5
handler: sweep every identity-resolving read, and fix the two that we…
distronode-com Sep 6, 2026
d0d5860
handler: one hot client per workspace, not one per process
distronode-com Sep 6, 2026
4522ff6
calendar,server: bind the providers to the workspace, and stop primin…
distronode-com Sep 6, 2026
079eff0
worker: claim across tenants on the platform handle, work inside one
distronode-com Sep 6, 2026
a596e16
handler: reconcile one workspace at a time, enumerated on the platfor…
distronode-com Sep 6, 2026
9ff86dc
cmd: scope reset-admin to a workspace, refuse mcp stdio, and inventor…
distronode-com Sep 6, 2026
8a93fad
Merge feat/platform-hooks into feat/multi-tenant
distronode-com Sep 6, 2026
fb13717
PROGRESS: the platform-hooks merge, and the D11 plan for B6
distronode-com Sep 6, 2026
3c33e7a
handler: a reschedule's reminder must not lose to the create that pre…
distronode-com Sep 6, 2026
be4ce91
handler: the platform API — provision, read, patch and delete a works…
distronode-com Sep 6, 2026
6a167ba
PROGRESS: the reminder race as an upstream-first candidate, and the t…
distronode-com Sep 6, 2026
2a3dfad
handler: workspace export, import and attendee erasure (D12)
distronode-com Sep 6, 2026
db0180b
handler: a provisioned workspace subscribes to every event we emit, n…
distronode-com Sep 6, 2026
e0d2b4a
test: the reminder-race control has to survive -race on a loaded box
distronode-com Sep 6, 2026
d2a5ddb
handler: the SSO hand-off and the OAuth login, both halves (D11)
distronode-com Sep 6, 2026
c9b80a2
handler: vendor webhooks resolve their own workspace, and scoped hand…
distronode-com Sep 6, 2026
00b3f6d
server: the tenancy proof — every route, tool and job type, derived n…
distronode-com Sep 6, 2026
af0ffd7
server: measure what a tenant costs — 200 workspaces, 34.6 KB each
distronode-com Sep 6, 2026
1a6c5d9
docs: MULTI_TENANT.md, and PROGRESS closes boundaries 1–7
distronode-com Sep 6, 2026
85550e8
handler, server: read the two per-tenant settings columns instead of …
distronode-com Sep 6, 2026
11618e9
config: DATA_DIR chooses the upload directory
distronode-com Sep 6, 2026
7299edd
chore: drop the fork's branch log from the pull request
distronode-com Sep 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,59 @@ jobs:

- name: go test
run: go test ./...

# The same suite against PostgreSQL. Separate job rather than a matrix on `check`
# because only the Go half is engine-dependent: svelte-check and pnpm build would
# run twice for no reason. The frontend build is still needed here, since the Go
# binary go:embeds frontend/build and `go build` fails without it.
postgres:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: calnode_ci
POSTGRES_DB: calnode
ports:
- 5432:5432
# Without a health check the first connection races the server's startup,
# which fails as "connection refused" and reads like a bad DSN.
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 10.32.1

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml

- name: Install frontend deps
working-directory: frontend
run: pnpm install --frozen-lockfile

- name: Build frontend (required by the Go embed)
working-directory: frontend
run: pnpm build

- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

# CALNODE_TEST_POSTGRES_DSN is what switches internal/dbtest onto Postgres.
# Unset — which is every other job and every local run — the suite uses
# in-memory SQLite exactly as before, so this job is additive: it cannot change
# what a contributor sees.
- name: go test (PostgreSQL)
env:
CALNODE_TEST_POSTGRES_DSN: postgres://postgres:calnode_ci@127.0.0.1:5432/calnode?sslmode=disable
run: go test ./...
102 changes: 102 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,108 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad

## [Unreleased]

### Added
- **Canadian French (`fr-CA`) on the booker-facing surfaces.** A visitor whose browser asks
for `fr-CA` now gets Canadian French rather than the France copy; `fr` and `fr-FR` are
unaffected. It is the first regional locale, and a separate file rather than a fallback
because the differences are real: `courriel` rather than `e-mail`, `reporter`/`report`
rather than `reprogrammer`, `renseignements personnels` (the Quebec statutory term) rather
than `données personnelles`, no space before `!` `?` `;` where France puts one, and CLDR
itself spells July `juill.` here against `juil.` in France.

⚠️ **The wording is an unreviewed draft**, like every non-English locale in this
repository: the structure is verified by the same three guards (same keys, printf-verb
parity, date tables cross-checked against CLDR), but no native Canadian French speaker has
read the copy. Corrections are welcome and easy to merge — see CONTRIBUTING.

- **`booking.reminder` webhook event.** Reminders were email-only, so an integration had no
way to know one had gone out — you could hear about a booking being made, moved or
cancelled, but not about the nudge before it. Subscribe to it in Settings → Webhooks.

The payload is booking-shaped like the other booking events plus `hours_before`, because
an event type can configure several reminders and a subscriber needs to know which one
fired. It is sent after the email and only when the email succeeded: the event means the
attendee has been reminded, and the job retries, so firing it on a failed send would be
both untrue and eventually duplicated. A host who has reminder emails switched off sends
no reminder, so there is no event either.

- **`STT_BASE_URL`: choose which speech-to-text endpoint transcribes your recordings.**
The host was hardcoded, so meeting audio always went to the provider's global endpoint —
a problem if you need it transcribed inside one jurisdiction. The default is unchanged.

Only the host is configurable; the path, model and transcription options stay ours, so
this picks a region rather than a different request. The effective value is reported
read-only as `stt_base_url` in `GET /v1/settings/notetaker`, because an admin should be
able to see where audio is sent without reading a running container's environment — and
should not be able to repoint it from a browser session, which is why it is not a
settings field.

- **`GET /metrics`: Prometheus metrics, off until you set `METRICS_TOKEN`.** Build
identity, requests by surface and status, a request-duration histogram, pending and
failed job counts, bookings created/cancelled/rescheduled, process start time and two Go
runtime gauges. No new dependency — the exposition format is a page of text, and a
scrape endpoint is not worth a dependency tree in a binary you self-host.

Without the token, and with a wrong one, it answers 404 rather than 401: these numbers
are a business feed, and an operator who has not configured a token has not agreed to
publish it, so there is nothing to advertise either. The `class` label comes from the
path prefix and nothing else, so the series count is fixed at five times the handful of
status codes and a request cannot invent a new one.

- **`FRAME_ANCESTORS`: embed the admin UI in your own console.** Space-separated origins
(`https://console.example.com 'self'`); when set, `/admin/` sends
`Content-Security-Policy: frame-ancestors <list>`. The public booking pages are
untouched and still deny framing outright — this is about the console, not the pages
that take card details.

Two deliberate refusals. An entry that is not `https://host[:port]` or `'self'` stops
the app booting rather than being ignored, because a browser drops a source list it
cannot parse, which would leave the admin UI *more* embeddable than the setting being
unset. And no `X-Frame-Options` is sent beside it: that header has no allow-list form,
so the only value it could carry is `SAMEORIGIN`, which browsers honour instead of the
CSP and would break the embedding this exists for.

- **`TRUSTED_PROXY_CIDRS`: per-IP rate limits that work behind a CDN.** Rate limits key
on the TCP peer, which is right for a directly-reachable instance and useless behind a
fronting CDN, where every visitor arrives from the same handful of addresses and shares
one bucket. List the networks you control and the client IP is taken from
`CF-Connecting-IP`, or from `X-Forwarded-For` walked right to left past your own hops.

Nothing changes if you do not set it: a header from a peer you have not listed is still
not read at all, because it is a value the client chose. Within the header the *leftmost*
entry is likewise client-chosen, so the walk stops at the rightmost address one of your
proxies actually observed, and a malformed hop ends the walk on the peer rather than
being stepped over.

- **Sign out everywhere.** `POST /v1/auth/sessions/revoke-all` ends every session you
have except the one you asked from, so losing a laptop no longer means waiting out a
30-day cookie. Pass `{"user_id": "..."}` and an admin can do the same for someone
else: an admin may revoke a member, only the owner may revoke another admin, and the
owner's own sessions can only be ended by the owner.

It also revokes that person's MCP OAuth tokens, which is the part that makes it an
offboarding tool rather than a convenience. A connected agent authenticates with a
bearer token and not the session cookie, so ending the sessions alone would have left
it holding exactly the access that was just withdrawn.

- **Signed session hand-off, so an identity system you already run can sign people in.**
`GET /v1/auth/sso?token=<jwt>` accepts a short-lived HS256 JWT signed with a shared
secret and starts an ordinary Calnode session, redirecting to `/admin/` (or to a
same-origin `?next=` path). Off unless `CALNODE_SSO_SHARED_SECRET` is set — an
unconfigured instance answers 404, so it cannot be turned on by accident.

The token must carry `iss`, `aud` (your `BASE_URL`), `sub` (email), `name`, `role`,
`iat`, `exp` and a unique `jti`. It may live at most 60 seconds, 30 seconds of clock
skew is tolerated either way, and the `jti` is recorded in a new `sso_nonces` table
before the session is created, so a replay inside that window is refused rather than
handed a second session. A `wid` claim is accepted and ignored today.

This is the only path that creates a user without an invite, which is the trade the
shared secret buys: the caller is your own identity system, not a visitor with a
Google account. On creation the claimed role is applied; for someone who already
exists the role is left alone, except that a claim asking for `owner` bootstraps
ownership when the instance has none. Archived accounts are still refused.

## [0.8.0] - 2026-09-03

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ no string plumbing with the Go templates), and admin-authored content (event nam
descriptions, questions, custom email copy). Locale is resolved per request from
`Accept-Language` + a `?lang=` override + the operator's fallback setting
(`internal/handler/i18n.go`), and the booker's locale is stored on the booking so later
reminders match. Ships `en es fr de it pt nl sv`. Full detail: ARCHITECTURE §23.
reminders match. Ships `en es fr fr-CA de it pt nl sv`. Full detail: ARCHITECTURE §23.

**Adding a locale = adding `internal/i18n/locales/<code>.json`.** Nothing else. `init()`
globs the directory; the switcher, the fallback dropdown and the public API payload all read
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ prefer additive, nullable/defaulted columns.

## Translations

Calnode ships 8 locales (`en es fr de it pt nl sv`) across the booker-facing surfaces:
Calnode ships 9 locales (`en es fr fr-CA de it pt nl sv`) across the booker-facing surfaces:
booking page, manage/reschedule page, embed widget, the four emails, and the calendar
invite. The admin UI and the built-in video room are English-only.

Expand Down
9 changes: 8 additions & 1 deletion DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,24 @@ This guide covers a generic Docker deploy and a step-by-step **Railway** deploy
|---|---|---|---|
| `CALNODE_ENCRYPTION_KEY` | **prod: yes** | — | KEK input (Argon2id). **Required when `BASE_URL` is https** — the app refuses to start without it. Use a long random string: `openssl rand -hex 32`. **Losing it makes encrypted data unrecoverable** unless you set the recovery secret below. |
| `CALNODE_RECOVERY_SECRET` | recommended | — | Escrow secret so the data key can be recovered if the encryption key is rotated/lost. Store it somewhere separate. |
| `CALNODE_SSO_SHARED_SECRET` | no | — | HMAC key for the signed session hand-off (`GET /v1/auth/sso`). Unset ⇒ that endpoint **404s**. Anything holding this secret can mint a session and create a user, so treat it like the encryption key: `openssl rand -hex 32`, env only, never in the admin UI. |
| `METRICS_TOKEN` | no | — | Bearer token for `GET /metrics` (Prometheus text exposition). Unset ⇒ that endpoint **404s**, so an instance never publishes its request volume, booking rate or queue depth by accident. Scrape with `Authorization: Bearer $METRICS_TOKEN`; a wrong token gets the same 404 as an unconfigured one. |
| `BASE_URL` | **yes (prod)** | `http://localhost:3000` | Identity host — admin UI, OAuth callbacks, invite links. **Must include the scheme** (`https://booking.example.com`). The `https://` prefix flips the app into production mode (secure cookies, encryption-key enforcement). |
| `PUBLIC_BASE_URL` | no | = `BASE_URL` | Booker-facing host for booking links/emails, if different from the identity host. |
| `DATABASE_URL` | no | `sqlite://./data/calnode.db` | Point at the persistent volume, e.g. `sqlite:///data/calnode.db`. |
| `DATABASE_URL` | no | `sqlite://./data/calnode.db` | Point at the persistent volume, e.g. `sqlite:///data/calnode.db`. A `postgres://user:pass@host:5432/dbname` URL selects PostgreSQL instead; anything else is SQLite. |
| `DB_MAX_OPEN_CONNS` | no | `10` | **PostgreSQL only.** Size of the connection pool. It has to fit inside the server's own `max_connections`, shared with every other client — raise it for a busy instance on a well-sized server, lower it behind PgBouncer or on a shared one. Must be a positive integer; anything else is ignored (with a warning) and the default stands. **Ignored on SQLite, which is always 1**: the single connection is what serialises write transactions, not a tuning choice. |
| `DB_MAX_IDLE_CONNS` | no | `5` | **PostgreSQL only.** How many idle connections the pool keeps rather than closing. Positive integer, and capped at `DB_MAX_OPEN_CONNS` (a larger value is clamped, since `database/sql` would silently do the same). |
| `PORT` | no | `3000` | The app listens on `$PORT`. Many platforms inject their own (Railway injects `8080`) — let them. |
| `EMAIL_SMTP_HOST` / `_PORT` / `_USER` / `_PASS` | no¹ | — / `587` | SMTP. Can also be set later in Settings → Email (DB-stored, encrypted). |
| `EMAIL_SMTP_TLS` / `_STARTTLS` | no | `false` | `STARTTLS` for 587, implicit `TLS` for 465. |
| `EMAIL_FROM_ADDRESS` / `EMAIL_FROM_NAME` | no | `bookings@localhost` / `Calnode` | The From identity. |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | no | — | Google sign-in + calendar. Can also be set in Settings → Google OAuth. |
| `LITESTREAM_REPLICA_URL` | recommended | — | Enables continuous SQLite backup (see §6). |
| `COOKIE_SECURE` | no | https→true | Override cookie Secure flag; defaults from `BASE_URL` scheme. |
| `TRUSTED_PROXY_CIDRS` | no | — | Comma-separated CIDRs (a bare address = one host) whose `CF-Connecting-IP` / `X-Forwarded-For` are believed when keying per-IP rate limits, e.g. `10.0.0.0/8`. Unset ⇒ those headers are ignored and the limit keys on the TCP peer, so behind a fronting CDN every visitor shares one bucket. **Only list networks you control**: anything in the list can name any client IP it likes. |
| `FRAME_ANCESTORS` | no | — | **Space**-separated origins allowed to embed the **admin UI** in a frame, e.g. `https://console.example.com 'self'`. Each entry must be `https://host[:port]` or `'self'` — anything else and **the app refuses to start**, because browsers drop a policy they cannot parse. Does not affect the public booking pages, which always deny framing. |
| `LOG_LEVEL` | no | `info` | `debug`/`info`/`warn`/`error`. |
| `STT_BASE_URL` | no | `https://api.deepgram.com` | Speech-to-text endpoint **host** for meeting transcription, e.g. a regional endpoint so recording audio stays in one jurisdiction. Host only — the path, model and options are fixed. Shown read-only in Settings → Notetaker as `stt_base_url`. |

¹ Email is optional to boot, but bookings won't send confirmations until SMTP is configured (env **or** the admin UI). Precedence is **env var > DB setting > default**.

Expand Down
31 changes: 21 additions & 10 deletions audit/claims.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -276,25 +276,36 @@ claims:

- id: rate-limit-keys-on-tcp-source-address
claim: >
Per-IP rate limiting (internal/server/middleware.go's RateLimit) keys
strictly on the TCP-level remote address of the connection, never on client-
supplied X-Forwarded-For/X-Real-IP headers — a client cannot spoof those
headers to evade or split its rate-limit bucket.
Per-IP rate limiting (internal/server/middleware.go's RateLimit) keys on the
TCP-level remote address of the connection, and never on client-supplied
X-Forwarded-For/X-Real-IP/CF-Connecting-IP headers unless the peer that sent
them is inside an operator-configured TRUSTED_PROXY_CIDRS range — so a client
cannot spoof those headers to evade or split its rate-limit bucket.
verify:
- "internal/server/middleware.go's remoteIP — net.SplitHostPort(r.RemoteAddr)
only; the proxy headers are never read."
via peerIP, unless TrustClientIP has resolved a client IP into the request
context, which it only does for a peer matching a trusted CIDR."
- "internal/server/middleware.go's resolveClientIP — returns the peer outright
for an untrusted peer, before any header is read."
- "internal/server/ratelimit_test.go's TestRemoteIP_* — assert X-Forwarded-For
and X-Real-IP are ignored even when RemoteAddr is loopback."
- "internal/server/trustedproxy_test.go — asserts an untrusted peer's spoofed
headers are ignored, that the X-Forwarded-For walk goes right-to-left past
trusted hops (never the client-seeded leftmost entry), and that a malformed
header falls back to the peer."
status: verified
caveat: >
Recorded here because a prior Layer 2 audit pass flagged this as trusting
spoofable proxy headers — it doesn't; the flagged behavior traced back to a
stale doc comment describing the opposite of what the code does (fixed
alongside this entry). Correct behavior does require the deployment's
reverse proxy to connect to Calnode directly (or over a trusted private
network) — see the deployment docs for reverse-proxy requirements (forward
the original Host header, connect over a trusted path, strip client-supplied
proxy headers at the edge).
alongside this entry). TRUSTED_PROXY_CIDRS is empty by default, so an
unconfigured instance behaves exactly as this claim originally described.
Anything an operator does list can name any client IP it likes — that is what
trusting a proxy means — so the list must hold only networks they control.
Correct behavior otherwise requires the deployment's reverse proxy to connect
to Calnode directly (or over a trusted private network) — see the deployment
docs for reverse-proxy requirements (forward the original Host header, connect
over a trusted path, strip client-supplied proxy headers at the edge).

- id: caldav-connect-self-service-no-admin-gate
claim: >
Expand Down
Loading
Loading