diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa6a5dd..60da712 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 ./... diff --git a/CHANGELOG.md b/CHANGELOG.md index 1445c11..dd5ad5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `. 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=` 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 diff --git a/CLAUDE.md b/CLAUDE.md index 0037a37..64c0dce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.json`.** Nothing else. `init()` globs the directory; the switcher, the fallback dropdown and the public API payload all read diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 148c9d2..76870aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/DEPLOY.md b/DEPLOY.md index e5e6297..97ed1c2 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -25,6 +25,8 @@ 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`. | @@ -35,7 +37,10 @@ This guide covers a generic Docker deploy and a step-by-step **Railway** deploy | `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**. diff --git a/audit/claims.yaml b/audit/claims.yaml index 28bca98..14c4647 100644 --- a/audit/claims.yaml +++ b/audit/claims.yaml @@ -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: > diff --git a/cmd/calnode/main.go b/cmd/calnode/main.go index 29a38aa..390ffcd 100644 --- a/cmd/calnode/main.go +++ b/cmd/calnode/main.go @@ -53,20 +53,27 @@ func main() { bi := buildinfo.Get() logger.Info("starting calnode", "version", bi.Version, "commit", bi.Commit, "build_time", bi.BuildTime, "dirty", bi.Dirty) + // Config whose wrong value is worse than its absence is checked here rather than + // tolerated at request time — see (*config.Config).Validate. + if err := cfg.Validate(); err != nil { + logger.Error("invalid configuration", "error", err) + os.Exit(1) + } + if cfg.GoogleClientID != "" { slog.Info("Google OAuth configured", "client_id_prefix", cfg.GoogleClientID[:20]) } else { slog.Warn("Google OAuth NOT configured — GOOGLE_CLIENT_ID is empty") } - database, err := db.Open(cfg.DatabaseURL) + database, err := db.OpenDB(cfg.DatabaseURL) if err != nil { logger.Error("failed to open database", "error", err) os.Exit(1) } defer database.Close() - if err := db.Migrate(database); err != nil { + if err := database.Migrate(); err != nil { logger.Error("failed to run migrations", "error", err) os.Exit(1) } diff --git a/cmd/calnode/mcp.go b/cmd/calnode/mcp.go index aa6984d..9eca8a2 100644 --- a/cmd/calnode/mcp.go +++ b/cmd/calnode/mcp.go @@ -30,14 +30,14 @@ func runMCPStdio(_ []string) { logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: cfg.LogLevel})) slog.SetDefault(logger) - database, err := db.Open(cfg.DatabaseURL) + database, err := db.OpenDB(cfg.DatabaseURL) if err != nil { logger.Error("mcp: failed to open database", "error", err) os.Exit(1) } defer database.Close() - if err := db.Migrate(database); err != nil { + if err := database.Migrate(); err != nil { logger.Error("mcp: failed to run migrations", "error", err) os.Exit(1) } diff --git a/cmd/calnode/recover_key.go b/cmd/calnode/recover_key.go index ebd285f..a7acf51 100644 --- a/cmd/calnode/recover_key.go +++ b/cmd/calnode/recover_key.go @@ -36,7 +36,7 @@ func runRecoverKey(args []string) { dbURL = "sqlite://./data/calnode.db" } - database, err := db.Open(dbURL) + database, err := db.OpenDB(dbURL) if err != nil { fmt.Fprintf(os.Stderr, "error: open database: %v\n", err) os.Exit(1) diff --git a/cmd/calnode/reset_admin.go b/cmd/calnode/reset_admin.go index 03495b4..4f2a2a7 100644 --- a/cmd/calnode/reset_admin.go +++ b/cmd/calnode/reset_admin.go @@ -33,7 +33,7 @@ func runResetAdmin(args []string) { cfg := config.Load() - database, err := db.Open(cfg.DatabaseURL) + database, err := db.OpenDB(cfg.DatabaseURL) if err != nil { fmt.Fprintf(os.Stderr, "error: failed to open database: %v\n", err) os.Exit(1) diff --git a/cmd/calnode/rotate_key.go b/cmd/calnode/rotate_key.go index f1d1085..000fb9c 100644 --- a/cmd/calnode/rotate_key.go +++ b/cmd/calnode/rotate_key.go @@ -37,7 +37,7 @@ func runRotateKey(args []string) { dbURL = "sqlite://./data/calnode.db" } - database, err := db.Open(dbURL) + database, err := db.OpenDB(dbURL) if err != nil { fmt.Fprintf(os.Stderr, "error: open database: %v\n", err) os.Exit(1) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e81e86c..218a354 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -54,6 +54,15 @@ app you must `pnpm build` in `frontend/` **and** rebuild/restart the Go binary - `MICROSOFT_CLIENT_ID/SECRET` and `MICROSOFT_TENANT` (default `common`; use the multi-tenant `common` so any work/personal Microsoft account can connect/sign in) - `COOKIE_SECURE` (defaults true when BASE_URL is https) + - `STT_BASE_URL` — speech-to-text endpoint **host** for the notetaker; defaults to + `stt.DefaultBaseURL` (`https://api.deepgram.com`). Set it to a regional endpoint to + keep recording audio inside one jurisdiction. Only the host is configurable: the path, + model and transcription options stay Calnode's (`internal/stt`'s `listenPath`), so an + operator picks a region and not a different request. Surfaced **read-only** as + `stt_base_url` in `GET /v1/settings/notetaker` — an admin should be able to read where + audio is sent without shelling into the container, and should not be able to repoint it + from a browser session, which is why it is env-only rather than a DB setting like the + API key beside it. - Startup (`internal/server/server.go: New`): open DB → run goose migrations → open keyvault (unwrap DEK) → configure mailer (DB settings override env) → start webhook/reminder **worker** → load Google creds (DB > env) → build one @@ -63,19 +72,60 @@ app you must `pnpm build` in `frontend/` **and** rebuild/restart the Go binary return handler + a `drain` func. - Ops endpoints: `GET /healthz`, `GET /readyz` (readiness gate), `GET /version` (build stamp from `internal/buildinfo`). +- **`GET /metrics`** — Prometheus text exposition, hand-written in `internal/metrics` + (no client library, for the reason `internal/livekit` signs its own tokens: a + one-page stable text protocol is not worth a dependency tree in a self-hosted + binary). Series: `calnode_build_info{version,commit}`, + `calnode_http_requests_total{class,status}`, + `calnode_http_request_duration_seconds` (histogram, fixed buckets), + `calnode_jobs_pending`, `calnode_jobs_failed_total`, + `calnode_bookings_total{event}` for created/cancelled/rescheduled, + `process_start_time_seconds`, `go_goroutines`, `go_memstats_alloc_bytes`. + ⛔ **Gated on `Authorization: Bearer $METRICS_TOKEN`, and it answers 404** — identical + to the mux's own not-found — when the token is unset or wrong. Not 401: a 401 confirms + the endpoint exists, and these numbers are a business feed (bookings per hour, request + volume by surface) on an instance meant to be publicly reachable. Not rate-limited + either; a scrape runs every few seconds by design and a limiter would punch gaps that + read as downtime. + `class` is derived from the path **prefix only** (`public|admin|api|mcp|ops`), so the + label set is closed and can never be attacker-chosen — the usual way a metrics endpoint + becomes an out-of-memory vector. Read it as "which surface of the URL space", not "how + the request authenticated": `POST /v1/bookings` is public and unauthenticated and still + counts as `api`. Requests are counted in the existing `Logging` middleware, the only + place that already knows the final status and elapsed time; job depth is read from the + `jobs` table per scrape, because any instance can claim any job. A host **reassignment** + fires the `booking.rescheduled` webhook but is deliberately **not** counted as a + reschedule — it does not move the meeting. - Graceful shutdown drains the worker and in-flight requests before `db.Close()`. --- -## 4. Persistence (SQLite) — and the single-connection rule - -- `internal/db`: opens SQLite with **`SetMaxOpenConns(1)`** + `SetMaxIdleConns(1)`, - **WAL** journal mode, `busy_timeout=5000`. One writer connection by design. -- Migrations: **goose** SQL files in `internal/db/migrations/` (00001→00029). Run - automatically on startup. `ALTER TABLE ADD COLUMN` is reversible-by-convention - only (SQLite can't easily drop columns). - -### ⚠️ The single-connection gotcha (bit us once) +## 4. Persistence (SQLite or PostgreSQL) — and the single-connection rule + +- `internal/db`: `OpenDB(DATABASE_URL)` picks the engine from the URL scheme. A + `postgres://` URL selects PostgreSQL; everything else (`sqlite://./rel`, + `sqlite:///abs`, `:memory:`, a bare path) selects SQLite, so every configuration + that worked before still works unchanged. +- SQLite: **`SetMaxOpenConns(1)`** + `SetMaxIdleConns(1)`, **WAL** journal mode, + `busy_timeout=5000`. One writer connection by design. +- PostgreSQL: a normal pool (10 open / 5 idle). The single connection above is a + SQLite constraint, not a Calnode design choice, and carrying it over would + serialise the whole instance on an engine with its own concurrency control. +- The handle rebinds placeholders: Calnode's SQL is written once with `?` and + rewritten to `$1…$n` on PostgreSQL. ⚠️ `db.DB` embeds `*sql.DB` and the field is + exported, so `h.DB.Query(...)` compiles and **skips rebinding** — it passes on + SQLite and fails on PostgreSQL with a syntax error far from the edit. Call the + wrapper's own methods. The handful of statements no single spelling covers use + `Dialect.SQL(sqlite, postgres)`. +- Timestamps are computed in Go (`internal/dbtime`) rather than by the engine; + `datetime('now')` and `strftime` do not exist in PostgreSQL. `dbtime` keeps the + two layouts the schema already stores, byte-identical to what SQLite wrote. +- Migrations: **goose** SQL files in `internal/db/migrations/{sqlite,postgres}/` — + one schema, two spellings, same version numbers. Run automatically on startup. + `ALTER TABLE ADD COLUMN` is reversible-by-convention only (SQLite can't easily + drop columns). + +### ⚠️ The single-connection gotcha (bit us once) — SQLite only 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 @@ -85,9 +135,32 @@ 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. +This is a consequence of the single connection, so it does not apply on +PostgreSQL — but the materialise-first pattern must stay, because it is the only +thing keeping those three paths working on SQLite. + +### The double-booking guarantee, per engine + +The app-level overlap check reads "is this host free?" and then writes on the +answer. What keeps that free of TOCTOU races differs: + +- **SQLite** — booking transactions serialize on the single connection, so the + check reliably guards **all** hosts, not just the one the partial unique index + covers. +- **PostgreSQL** — the pool has many connections, so two overlapping bookings could + both clear the check. `booking.lockHosts` takes **`pg_advisory_xact_lock`** on + each host id before the first overlap read, in `Create`, `Reschedule` and + `ReassignHost`. The key is SHA-256 of `"calnode:booking:host:" + hostID`, first + eight bytes as an int64; ids are locked in sorted order so two transactions + needing the same pair cannot deadlock. The lock ends with the transaction, so + there is nothing to release. On SQLite it is a no-op. + +Both engines also carry the partial unique index +`idx_bookings_no_double (host_id, start_at) WHERE status != 'cancelled'`. It catches +an **identical** start time and nothing else — a partial overlap is two distinct +keys — which is why the app-level check and the lock exist and why all three stay. +Measured on PostgreSQL, 40 races between overlapping-but-not-identical slots: with +the lock, 0 double bookings; without it, 39, of which the index caught one. ### Data model (key tables) @@ -186,6 +259,43 @@ the platform/recovery secret doesn't expose secrets. 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. +- **Sign out everywhere** (`POST /v1/auth/sessions/revoke-all`, `session.go`). With no + body it drops all of the caller's sessions **except the one that made the request** — + "sign out my other devices", as distinct from `POST /v1/auth/logout`, which ends the + current one. (An API-key caller has no current session, so for them every session + goes.) With `{"user_id": "..."}` it is an offboarding tool, gated on the same tiers as + `roles.go`: an admin may revoke a member, only the owner may revoke another admin, and + the owner's sessions are reachable only by the owner. The actor's tier is checked + *before* the target is loaded, so the 404 cannot be used to enumerate user ids. + ⛔ It also deletes the target's rows in **`oauth_access_tokens`**, cutting off any MCP + connector (§19) — those authenticate with a bearer token, not the session cookie, so + revoking sessions alone would leave an agent holding the authority just withdrawn. + Both deletes run in one transaction, so "revoked" is never half-true. +- **Signed session hand-off** (`GET /v1/auth/sso?token=`, `sso.go`) lets an + external identity system that has already authenticated someone drop them into a + Calnode session without a second login. **Off unless `CALNODE_SSO_SHARED_SECRET` is + set** — an unconfigured instance answers **404**, deliberately indistinguishable from + a build without the feature. The token is a compact **HS256** JWT verified in-tree + (`crypto/hmac`, like `internal/livekit`'s signing) with a constant-time compare; only + HS256 is accepted, checked *before* the signature so the `alg: none` downgrade never + reaches it. Claims: `iss` (any non-empty string, logged only), `aud` = `BASE_URL` + (what stops a staging token being spent on production when a secret is shared by + mistake), `sub` = email, `name`, `role` ∈ owner|admin|member, `iat`, `exp` at most + **60 s** after `iat` with **30 s** of clock skew allowed either way, and a unique + `jti`. `wid` is parsed and ignored — a multi-workspace mode will use it. The `jti` is + claimed in **`sso_nonces`** *before* the session is created, so a replay inside the + validity window collides on the primary key rather than racing a read-then-write; the + worker purges expired rows in its GC pass (§13). Success is a 302 to `/admin/`, or to + `?next=` when that is a same-origin absolute path (anything with a scheme, a `//` + prefix, a backslash or a control character is a 400, refused rather than sanitised). + Every other failure is a 401 whose JSON body names the claim that failed. Rate-limited + like the OAuth callbacks. + ⛔ **This is the one path that creates a user without an invite.** Everywhere else an + unknown email is refused (`no_account`); here the shared secret is the difference — the + caller is the operator's own identity system. On creation the claim's `role` is applied; + on an existing user the role is **not** rewritten, except that a claim asking for + `owner` bootstraps ownership when the instance has none (the one-owner invariant §6 + maintains means there is nothing to displace). An archived user is still refused. - **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). @@ -264,7 +374,7 @@ members. 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 + serialize on SQLite's single connection (fast); only the network overlaps. Response includes a `hosts` metadata map (id→name/avatar) for rendering faces. - **Busy** for a host = every non-cancelled booking they attend, via `booking_hosts` (NOT just `bookings.host_id`) — so a non-primary Group/fixed seat @@ -635,14 +745,27 @@ as the desired state: reset to pending +1 min). Retry **backoff is a fixed two-step: 60s then 5 min** (not exponential), `max_attempts` 3. Atomic claim via `UPDATE … WHERE status='pending'` + RowsAffected. -- `internal/webhook`: enqueues `booking.created` / `.cancelled` / `.rescheduled` - plus the notetaker events `recording.completed` / `transcript.ready` / `notes.ready` +- `internal/webhook`: enqueues `booking.created` / `.cancelled` / `.rescheduled` / + `.reminder` plus the notetaker events `recording.completed` / `transcript.ready` / + `notes.ready` (reference payloads — booking-shaped, keyed by id; consumers fetch the artifact body - via REST/MCP). There is **no** `booking.reminder` webhook event. Deliveries are signed + via REST/MCP). Deliveries are signed **HMAC-SHA256**, header `X-Calnode-Signature` (+ `X-Calnode-Event`/`-Delivery`), secret stored encrypted. The worker's HTTP client is **SSRF-guarded** (resolves DNS, blocks private/loopback/CGNAT/ULA IPs, dials the resolved IP to avoid re-resolution) since webhook URLs are user-supplied. +- **`booking.reminder`** is fired by the `reminder.send` job (`internal/worker`), booking-shaped + like its siblings plus **`hours_before`** — an event type can configure several reminders, so + the payload has to say which one this is. It carries no payment fields: those come from + create/cancel, and `paymentStatusForWebhook`'s mapping lives in the handler package. + ⛔ **Fired after the email and only on success**, because the event means "the attendee has + been reminded". Emitting it beside a failed send would be untrue, and the job retries — which + would deliver it twice for one reminder. Conversely an *enqueue* failure does **not** fail the + job: the email has already gone and a retry would send a second one, so it is logged and + dropped. `sendReminder`'s early returns (booking deleted, no longer confirmed, host has + reminder emails off) are all "no reminder happened", so none of them fires it either. + ⚠️ The event needs the **booking's** `host_id`, not the event type's owner — a rotation or a + reassignment moves it, and `Enqueue` selects a subscriber's webhooks by that id. - **Per-webhook payload fields:** each webhook chooses which fields land in the `data` object (`webhooks.fields` JSON, migration 00027) — incl. attendee PII + intake answers. NULL ⇒ the original default set (so existing webhooks are unchanged and @@ -694,17 +817,33 @@ as the desired state: **original `Host` header**. The CSRF same-origin check (§6) compares the request's `Origin`/`Referer` against `Host`, so a proxy that rewrites Host would *false-block admin writes* (403). Fly and Railway preserve Host by default; a hand-rolled nginx - needs `proxy_set_header Host $host;`. Related: per-IP rate limits (§8) key on the - **TCP remote address** (proxy headers like `X-Forwarded-For` are intentionally - ignored as forgeable), so behind a shared proxy the limit keys on the proxy's - connection — fine for per-instance Fly/Railway, worth knowing for a fronting proxy. + needs `proxy_set_header Host $host;`. +- **Per-IP rate limits (§8) key on the TCP remote address by default**, and + `X-Forwarded-For` / `X-Real-IP` / `CF-Connecting-IP` are not read at all. That is not + an oversight: those headers are client-chosen values, so believing them unconditionally + would let anyone split their own rate-limit bucket by sending a different one each + request. Behind a shared proxy the limit therefore keys on the proxy's connection — + fine for a per-instance Fly/Railway deploy, worth knowing for a fronting CDN. + **`TRUSTED_PROXY_CIDRS`** (comma-separated CIDRs, a bare address meaning one host) + opts in per network: for a peer inside one of those ranges, `TrustClientIP` + (`internal/server/middleware.go`) resolves the client IP from `CF-Connecting-IP` if + present, else by walking `X-Forwarded-For` **right to left past trusted hops** and + taking the first untrusted address, else the peer. ⛔ Not the leftmost entry: the left + of that header is whatever the original client sent, and every well-behaved proxy + preserves it. A hop that does not parse ends the walk and falls back to the peer rather + than being skipped, so one malformed entry cannot push the walk onto a value the client + chose. Headers from an **untrusted** peer are never read, which is what keeps the + default un-weakenable by a header. Resolution happens once, in the outermost + middleware, and is carried in the request context. --- ## 17. Cross-cutting gotchas (read before editing) 1. **SQLite single connection** — never query inside an open cursor; materialize - first (§4). + first (§4). Harmless on PostgreSQL, but the pattern stays either way. + On PostgreSQL the same single connection is also what the booking overlap check + loses, which `pg_advisory_xact_lock` replaces (§4). 2. **All times UTC** in storage; convert at the edges. The slot busy-window must be widened for tz boundaries. 3. **Frontend is embedded at compile time** — `pnpm build` + rebuild Go to see @@ -719,6 +858,24 @@ as the desired state: strict default and relaxes only when head code-injection is configured (broad `https:` or the operator's `tracking_csp_allow`). Don't re-hardcode the CSP on the `book`/`manage` handlers — route it through `publicCSP`. +9. **`FRAME_ANCESTORS` is the admin SPA's only, and it must stay that way.** Set it + (space-separated `https://host[:port]` / `'self'`) and the handler under `/admin/` + sends `Content-Security-Policy: frame-ancestors ` so an operator can embed the + console in their own tooling. `internal/server`'s `FrameAncestors` middleware wraps + `frontend.Handler()` and nothing else: the public booking pages keep + `frame-ancestors 'none'` + `X-Frame-Options: DENY` unconditionally, because they are + unauthenticated pages collecting names, emails and card details and clickjacking one + is worth more than framing a console nobody reaches without a session. + ⚠️ **Unset sends no frame header at all, which is what `/admin/` has always sent** — + the SPA is framable by default. This setting deliberately does *not* add a default + deny, since an opt-in flag must not smuggle in a behaviour change; + `TestAdminSPA_sendsNoFrameHeadersWhenUnset` pins the current answer so changing it is + a decision. No `X-Frame-Options` is sent beside the CSP either: that header has no + allow-list form (`ALLOW-FROM` is dead), so the only value it could carry is + `SAMEORIGIN`, which browsers apply *instead of* the CSP and would break the embedding. + An entry that isn't `https://host[:port]` or `'self'` fails `config.Validate()` and the + process **refuses to start** — a browser drops a source list it cannot parse, so a + typo would otherwise leave `/admin/` more embeddable than with the setting unset. --- @@ -984,7 +1141,18 @@ LLM summary) — the next build; consent-gated (§8.11/§15 of the PRD). ## 23. Languages (i18n) -Calnode ships **8 locales**: `en` (source) · `es` · `fr` · `de` · `it` · `pt` · `nl` · `sv`. +Calnode ships **9 locales**: `en` (source) · `es` · `fr` · `fr-CA` · `de` · `it` · `pt` · `nl` · `sv`. + +`fr-CA` is the first **regional** locale, and it is a separate file rather than a fallback +because the differences are real: `courriel` not `e-mail`, `reporter`/`report` not +`reprogrammer`/`reprogrammation`, `renseignements personnels` never `données personnelles` +(the Quebec statutory term), no space before `!` `?` `;` where France puts one, and CLDR +itself disagrees on one abbreviation — `month_short_jul` is `juill.` in fr-CA and `juil.` in +fr, which is exactly what `TestDateTablesMatchCLDR` exists to catch. Both keep the 24-hour +clock and the day-month `date_format`. Currency and percent take a non-breaking space before +`$` and `%` in Canadian French; no key carries either today, so the rule is recorded here +rather than applied. A visitor sending `fr-FR` or plain `fr` is unaffected — the matcher +picks the exact tag first (pinned in `TestResolve`). ### What is translated, and what is not diff --git a/frontend/src/routes/webhooks/+page.svelte b/frontend/src/routes/webhooks/+page.svelte index ed4eb83..0ed317e 100644 --- a/frontend/src/routes/webhooks/+page.svelte +++ b/frontend/src/routes/webhooks/+page.svelte @@ -18,6 +18,7 @@ 'booking.created', 'booking.cancelled', 'booking.rescheduled', + 'booking.reminder', 'recording.completed', 'transcript.ready', 'notes.ready' @@ -37,6 +38,7 @@ { key: 'cancellation_reason', label: 'Cancellation reason' }, { key: 'previous_start_at', label: 'Previous start (reschedule)' }, { key: 'previous_end_at', label: 'Previous end (reschedule)' }, + { key: 'hours_before', label: 'Hours before (reminder)' }, ] }, { group: 'Payment', fields: [ { key: 'payment_status', label: 'Payment status' }, diff --git a/go.mod b/go.mod index 8b8a9a9..73b76fd 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.26.6 require ( github.com/disintegration/imaging v1.6.2 + github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/pressly/goose/v3 v3.27.1 @@ -22,6 +23,9 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index 5dc4a22..73978d1 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= @@ -18,6 +19,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= @@ -40,6 +49,9 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -66,6 +78,8 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc= diff --git a/internal/booking/concurrency_test.go b/internal/booking/concurrency_test.go new file mode 100644 index 0000000..075cf64 --- /dev/null +++ b/internal/booking/concurrency_test.go @@ -0,0 +1,93 @@ +package booking_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/calnode/calnode/internal/booking" + "github.com/calnode/calnode/internal/dbtest" +) + +// TestCreate_concurrentPartialOverlap_postgres is the test for the guarantee +// pg_advisory_xact_lock replaces. +// +// The two slots deliberately OVERLAP without SHARING a start time. That is the case +// no index catches: idx_bookings_no_double is UNIQUE(host_id, start_at), so 10:00 +// and 10:15 are two distinct keys and both inserts satisfy it. The only thing +// standing between them is hostBusy's read, and on a multi-connection pool two +// transactions can both take that read before either writes. If the lock is not +// held, this test double-books. +// +// It is skipped on SQLite, where the race is not reachable: db.SetMaxOpenConns(1) +// means the second transaction cannot begin until the first has committed. +func TestCreate_concurrentPartialOverlap_postgres(t *testing.T) { + database := dbtest.RequirePostgres(t) + svc := booking.New(database) + hostID := seedHost(t, database) + etID := seedEventType(t, database, hostID) + + // Enough rounds that a lost race is very unlikely to go unseen. One round is + // not evidence: two goroutines miss each other's window often enough that an + // unlocked build passes a single round most of the time. + const rounds = 40 + + for round := 0; round < rounds; round++ { + // A fresh, non-overlapping window per round, so a round is independent of + // every earlier round's surviving booking. + base := slot(0, 0).Add(time.Duration(round) * time.Hour) + first := [2]time.Time{base, base.Add(30 * time.Minute)} + second := [2]time.Time{base.Add(15 * time.Minute), base.Add(45 * time.Minute)} + + var wg sync.WaitGroup + errs := make([]error, 2) + start := make(chan struct{}) + for i, window := range [2][2]time.Time{first, second} { + wg.Add(1) + go func(i int, from, to time.Time) { + defer wg.Done() + <-start // line both up so the transactions truly overlap + _, errs[i] = svc.Create(context.Background(), booking.CreateParams{ + EventTypeID: etID, + HostIDs: []string{hostID}, + StartAt: from, + EndAt: to, + Organizer: booking.Attendee{ + Name: "Alice", + Email: "alice@example.com", + }, + }) + }(i, window[0], window[1]) + } + close(start) + wg.Wait() + + var created int + for i, err := range errs { + switch { + case err == nil: + created++ + case errors.Is(err, booking.ErrDoubleBooked): + // the expected loser + default: + t.Fatalf("round %d: booking %d: unexpected error: %v", round, i, err) + } + } + if created != 1 { + t.Fatalf("round %d: %d of 2 overlapping bookings were created; want exactly 1 (errors: %v, %v)", + round, created, errs[0], errs[1]) + } + } + + // And the database agrees: one booking per round, never two in a window. + var n int + if err := database.QueryRow( + `SELECT COUNT(*) FROM bookings WHERE host_id = ? AND status != 'cancelled'`, hostID).Scan(&n); err != nil { + t.Fatalf("count bookings: %v", err) + } + if n != rounds { + t.Errorf("bookings for host = %d; want %d (one per round)", n, rounds) + } +} diff --git a/internal/booking/hostlock.go b/internal/booking/hostlock.go new file mode 100644 index 0000000..678cc10 --- /dev/null +++ b/internal/booking/hostlock.go @@ -0,0 +1,78 @@ +package booking + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + "slices" + + "github.com/calnode/calnode/internal/db" +) + +// hostLockDomain prefixes every id fed to the hash below, so a future advisory +// lock on some other entity cannot collide with a host's key by hashing the same +// raw string. +const hostLockDomain = "calnode:booking:host:" + +// lockHosts serialises the check-then-write window for a set of hosts, for the +// remainder of tx. +// +// Create, Reschedule and ReassignHost all read "is this host busy at this time?" +// and then write on the answer. On SQLite that is free of TOCTOU races without any +// locking, because the pool is a single connection (db.SetMaxOpenConns(1), see +// internal/db/db.go) and transactions therefore cannot interleave at all. A +// Postgres pool has many connections: two overlapping bookings can both read +// "free" and both insert, and the partial unique index +// idx_bookings_no_double(host_id, start_at) only catches an identical start time, +// not a partial overlap. That is exactly the gap the app-level check was never +// asked to close on its own. +// +// pg_advisory_xact_lock is held until the transaction commits or rolls back, so +// there is no unlock call to forget and no leak when a caller returns early — which +// every guard in these functions does. Locking per host rather than once globally +// keeps bookings for different hosts concurrent, which is the whole point of moving +// off the single connection. +// +// On SQLite this is a no-op and the existing guarantee stands unchanged. +func lockHosts(ctx context.Context, tx *db.Tx, hostIDs ...string) error { + if tx.Dialect() != db.DialectPostgres { + return nil + } + + // Sorted and deduplicated. Two transactions that needed the same two hosts in + // opposite orders would otherwise deadlock, and Postgres resolves a deadlock by + // killing one of them — a 500 on a booking that should have been a 409 or a + // success. Sorting is done on a copy: Create's HostIDs arrive in round-robin + // priority order and that order decides who gets the booking. + ids := slices.Clone(hostIDs) + slices.Sort(ids) + ids = slices.Compact(ids) + + for _, id := range ids { + if id == "" { + continue + } + if _, err := tx.ExecContext(ctx, + `SELECT pg_advisory_xact_lock(?)`, hostLockKey(id)); err != nil { + return fmt.Errorf("booking: lock host %s: %w", id, err) + } + } + return nil +} + +// hostLockKey maps a host id onto the single int64 key pg_advisory_xact_lock takes. +// +// SHA-256 of the domain-separated id, with the first eight bytes read big-endian as +// a signed integer. Deriving it in Go rather than with the engine's hashtext() keeps +// the derivation readable and testable from Go, and means the value arrives as an +// ordinary bound parameter. +// +// Two distinct hosts landing on the same key would cost an unnecessary +// serialisation between two unrelated bookings, never a wrong answer, so what this +// needs is stability and a good spread rather than cryptographic strength. SHA-256 +// is used because this package already imports it for manage tokens. +func hostLockKey(hostID string) int64 { + sum := sha256.Sum256([]byte(hostLockDomain + hostID)) + return int64(binary.BigEndian.Uint64(sum[:8])) +} diff --git a/internal/booking/hostlock_internal_test.go b/internal/booking/hostlock_internal_test.go new file mode 100644 index 0000000..eb8fce0 --- /dev/null +++ b/internal/booking/hostlock_internal_test.go @@ -0,0 +1,49 @@ +package booking + +import ( + "testing" + + "github.com/calnode/calnode/internal/db" +) + +// TestHostLockKey pins the derivation. The key must not move between processes or +// releases: two Calnode instances sharing one Postgres have to derive the same key +// for the same host, or they take different locks and serialise against nothing. +// +// The expected values were computed independently of this code (SHA-256 of +// "calnode:booking:host:" + id, first eight bytes big-endian as a signed integer), +// so this fails if the implementation drifts rather than agreeing with itself. +func TestHostLockKey(t *testing.T) { + cases := map[string]int64{ + "host-42": 7118598067704648523, + "host-43": 2949734679630933584, + } + for id, want := range cases { + if got := hostLockKey(id); got != want { + t.Errorf("hostLockKey(%q) = %d; want %d — the derivation changed, which desynchronises running instances", + id, got, want) + } + } +} + +// TestLockHosts_sqliteIsNoOp records the other half of the design: SQLite already +// has the guarantee, so the lock must not be attempted there. pg_advisory_xact_lock +// does not exist in SQLite, so if this ever started issuing the statement the error +// would be immediate. +func TestLockHosts_sqliteIsNoOp(t *testing.T) { + h, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("open: %v", err) + } + defer h.Close() + + tx, err := h.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() //nolint:errcheck + + if err := lockHosts(t.Context(), tx, "a", "b", "a", ""); err != nil { + t.Errorf("lockHosts on SQLite returned %v; want nil (it must be a no-op there)", err) + } +} diff --git a/internal/booking/service.go b/internal/booking/service.go index da604f9..2695e62 100644 --- a/internal/booking/service.go +++ b/internal/booking/service.go @@ -13,16 +13,17 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) // Service handles booking creation and lifecycle. type Service struct { - db *sql.DB + db *db.DB } // New returns a Service backed by db. -func New(db *sql.DB) *Service { +func New(db *db.DB) *Service { return &Service{db: db} } @@ -45,6 +46,17 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (*Booking, error) } defer tx.Rollback() //nolint:errcheck + // Before the first overlap read: every host whose availability this + // transaction is about to decide on. See lockHosts — on SQLite it does + // nothing, on Postgres it is what makes the checks below race-free. + lockIDs := make([]string, 0, len(p.HostIDs)+len(p.RequiredHosts)+len(p.OptionalHosts)) + lockIDs = append(lockIDs, p.HostIDs...) + lockIDs = append(lockIDs, p.RequiredHosts...) + lockIDs = append(lockIDs, p.OptionalHosts...) + if err := lockHosts(ctx, tx, lockIDs...); err != nil { + return nil, err + } + now := time.Now().UTC().Format(time.RFC3339Nano) // Select hosts. Round-robin picks one *free* candidate from the rotation pool @@ -124,7 +136,7 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (*Booking, error) SELECT COUNT(*) FROM bookings b JOIN booking_attendees a ON a.booking_id = b.id AND a.is_organizer = 1 WHERE b.event_type_id = ? AND b.status != 'cancelled' - AND b.end_at > ? AND a.email = ? COLLATE NOCASE`, + AND b.end_at > ? AND LOWER(a.email) = LOWER(?)`, p.EventTypeID, now, p.Organizer.Email).Scan(&active); err != nil { return nil, fmt.Errorf("booking: active-limit check: %w", err) } @@ -279,7 +291,7 @@ const bookingColumns = `id, event_type_id, host_id, start_at, end_at, status, // than matching bookings.host_id (which would miss a Group/fixed-host attendee). // excludeBookingID excludes the booking being modified from its own overlap check // (Reschedule/ReassignHost); pass "" when there's no booking yet to exclude (Create). -func hostBusy(ctx context.Context, tx *sql.Tx, hostID, start, end, excludeBookingID string) (bool, error) { +func hostBusy(ctx context.Context, tx *db.Tx, hostID, start, end, excludeBookingID string) (bool, error) { var n int err := tx.QueryRowContext(ctx, ` SELECT COUNT(*) FROM bookings b @@ -377,7 +389,7 @@ func (s *Service) ValidateManageToken(ctx context.Context, rawToken string) (*Bo // be in priority order (lowest priority number first). "priority" takes the first // free host; "even" (and "soonest", which has no meaning once the slot is fixed) // take the least-loaded one. -func pickRotationHost(ctx context.Context, tx *sql.Tx, eventTypeID, strategy string, free []string, now string) (string, error) { +func pickRotationHost(ctx context.Context, tx *db.Tx, eventTypeID, strategy string, free []string, now string) (string, error) { if strategy == "priority" { return free[0], nil } @@ -387,7 +399,7 @@ func pickRotationHost(ctx context.Context, tx *sql.Tx, eventTypeID, strategy str // leastLoadedHost returns the candidate with the fewest upcoming (non-cancelled, // not-yet-ended) bookings for this event type — even-distribution round-robin. // Ties are broken by the order of candidates (caller passes them in priority order). -func leastLoadedHost(ctx context.Context, tx *sql.Tx, eventTypeID string, candidates []string, now string) (string, error) { +func leastLoadedHost(ctx context.Context, tx *db.Tx, eventTypeID string, candidates []string, now string) (string, error) { ph := make([]string, len(candidates)) args := make([]any, 0, len(candidates)+2) args = append(args, eventTypeID, now) @@ -449,6 +461,15 @@ func (s *Service) Reschedule(ctx context.Context, bookingID string, newStart, ne return nil, ErrAlreadyCancelled } + // The primary host is locked before the host list is read, not after. A + // concurrent ReassignHost locks the booking's current primary too, so holding it + // here is what stops that reassignment committing between this read of + // booking_hosts and the UPDATE below — which would move the booking to a host + // whose availability was never checked. + if err := lockHosts(ctx, tx, b.HostID); err != nil { + return nil, err + } + // Every host on this booking keeps their seat through a reschedule, so each // must be free at the new time — not just the primary. Read the host list // fully before the per-host overlap queries (single-connection pool). @@ -469,6 +490,12 @@ func (s *Service) Reschedule(ctx context.Context, bookingID string, newStart, ne if len(hostIDs) == 0 { // legacy booking with no booking_hosts rows hostIDs = []string{b.HostID} } + // Now the rest of the seat holders, still before any overlap check. Re-locking + // the primary is free: an advisory lock already held by this transaction is + // re-entrant and is released once, at commit. + if err := lockHosts(ctx, tx, hostIDs...); err != nil { + return nil, err + } for _, hid := range hostIDs { busy, err := hostBusy(ctx, tx, hid, startStr, endStr, bookingID) if err != nil { @@ -527,6 +554,13 @@ func (s *Service) ReassignHost(ctx context.Context, bookingID, newHostID string) startStr := b.StartAt.UTC().Format(time.RFC3339Nano) endStr := b.EndAt.UTC().Format(time.RFC3339Nano) + // Both hosts: the new one because its availability is being decided, the old + // one because a concurrent Reschedule of this booking holds that same key and + // must not interleave with the host change. + if err := lockHosts(ctx, tx, b.HostID, newHostID); err != nil { + return nil, err + } + // The new host must be free at this time across everything they attend. busy, err := hostBusy(ctx, tx, newHostID, startStr, endStr, bookingID) if err != nil { @@ -652,7 +686,11 @@ func scanBooking(s scanner) (*Booking, error) { return &b, nil } -// isUniqueViolation reports whether err is a SQLite UNIQUE constraint failure. -func isUniqueViolation(err error) bool { - return strings.Contains(err.Error(), "UNIQUE constraint failed") -} +// isUniqueViolation reports whether err is a unique-constraint violation — on this +// booking path, idx_bookings_no_double rejecting an exact start-time collision that +// the app-level overlap check did not catch. +// +// A thin wrapper over db.IsUniqueViolation, kept only because three call sites read +// better with the local name and the doc comment above belongs to this path rather +// than to the shared helper. +func isUniqueViolation(err error) bool { return db.IsUniqueViolation(err) } diff --git a/internal/booking/service_test.go b/internal/booking/service_test.go index 9f656a0..18618a5 100644 --- a/internal/booking/service_test.go +++ b/internal/booking/service_test.go @@ -2,29 +2,22 @@ package booking_test import ( "context" - "database/sql" "testing" "time" "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/uid" ) -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open test db: %v", err) - } - t.Cleanup(func() { database.Close() }) - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate test db: %v", err) - } + database := dbtest.Open(t) return database } -func seedHost(t *testing.T, database *sql.DB) string { +func seedHost(t *testing.T, database *db.DB) string { t.Helper() id := uid.New() _, err := database.ExecContext(context.Background(), ` @@ -37,7 +30,7 @@ func seedHost(t *testing.T, database *sql.DB) string { return id } -func seedEventType(t *testing.T, database *sql.DB, userID string) string { +func seedEventType(t *testing.T, database *db.DB, userID string) string { t.Helper() id := uid.New() _, err := database.ExecContext(context.Background(), ` diff --git a/internal/caldav/caldav.go b/internal/caldav/caldav.go index cf143e7..d72635d 100644 --- a/internal/caldav/caldav.go +++ b/internal/caldav/caldav.go @@ -27,6 +27,7 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/connstore" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/netutil" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/uid" @@ -37,7 +38,7 @@ var _ calendar.Provider = (*Client)(nil) // Client manages CalDAV connections (encrypted app-password credentials) and access. type Client struct { - db *sql.DB + db *db.DB key [32]byte logger *slog.Logger hc *http.Client @@ -45,7 +46,7 @@ type Client struct { // New creates a Client. encKeyHex is the 64-char hex AES-256 encryption key (the same // instance key used to encrypt the other providers' tokens). -func New(db *sql.DB, encKeyHex string) (*Client, error) { +func New(db *db.DB, encKeyHex string) (*Client, error) { b, err := hex.DecodeString(encKeyHex) if err != nil || len(b) != 32 { return nil, fmt.Errorf("caldav: invalid encryption key") diff --git a/internal/caldav/caldav_test.go b/internal/caldav/caldav_test.go index ab24ed7..eba7a93 100644 --- a/internal/caldav/caldav_test.go +++ b/internal/caldav/caldav_test.go @@ -2,7 +2,6 @@ package caldav import ( "context" - "database/sql" "io" "net/http" "net/http/httptest" @@ -12,19 +11,14 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) const testKeyHex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return database } @@ -41,7 +35,7 @@ func newTestClient(t *testing.T) *Client { return c } -func seedUser(t *testing.T, database *sql.DB, userID string) { +func seedUser(t *testing.T, database *db.DB, userID string) { t.Helper() _, err := database.ExecContext(context.Background(), ` INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) @@ -267,7 +261,7 @@ func TestInvitesGuests_false(t *testing.T) { // ----- helpers ----- -func destEmail(t *testing.T, database *sql.DB, userID string) string { +func destEmail(t *testing.T, database *db.DB, userID string) string { t.Helper() var e string err := database.QueryRowContext(context.Background(), @@ -278,7 +272,7 @@ func destEmail(t *testing.T, database *sql.DB, userID string) string { return e } -func countConns(t *testing.T, database *sql.DB, userID string) int { +func countConns(t *testing.T, database *db.DB, userID string) int { t.Helper() var n int if err := database.QueryRowContext(context.Background(), diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index afaade9..f8e6696 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -10,6 +10,7 @@ import ( "sort" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/slots" ) @@ -82,13 +83,13 @@ type Provider interface { // Service holds the configured providers and dispatches per-user operations to // whichever provider that user has connected. type Service struct { - db *sql.DB + db *db.DB providers map[string]Provider primary string // default provider for new connections (first registered) } // NewService returns an empty Service. Register one provider per configured backend. -func NewService(db *sql.DB) *Service { +func NewService(db *db.DB) *Service { return &Service{db: db, providers: map[string]Provider{}} } diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go index 8f778e5..b066708 100644 --- a/internal/calendar/calendar_test.go +++ b/internal/calendar/calendar_test.go @@ -4,18 +4,11 @@ import ( "context" "testing" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) func TestCanAutoGenerate(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) ctx := context.Background() seed := func(userID, provider, kind string) { @@ -66,14 +59,7 @@ func TestCanAutoGenerate(t *testing.T) { // TestConnectionManagement covers the multi-calendar Service helpers: listing connections, // switching the single destination, and promoting a survivor when the destination is removed. func TestConnectionManagement(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) ctx := context.Background() if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) diff --git a/internal/calendar/conflicts.go b/internal/calendar/conflicts.go index 7da2998..946ece1 100644 --- a/internal/calendar/conflicts.go +++ b/internal/calendar/conflicts.go @@ -2,7 +2,8 @@ package calendar import ( "context" - "database/sql" + + "github.com/calnode/calnode/internal/db" ) // ConflictCalendarIDs resolves which calendar IDs of one connected account must be checked for @@ -17,7 +18,7 @@ import ( // // The caller must have no open rows cursor on the shared DB pool when calling this (the pool is // single-connection): drain and Close() any cursor first. -func ConflictCalendarIDs(ctx context.Context, db *sql.DB, provider, userID, accountEmail, fallbackCalID string) ([]string, error) { +func ConflictCalendarIDs(ctx context.Context, db *db.DB, provider, userID, accountEmail, fallbackCalID string) ([]string, error) { rows, err := db.QueryContext(ctx, `SELECT calendar_id, check_conflicts FROM connection_calendars WHERE user_id = ? AND provider = ? AND account_email = ?`, diff --git a/internal/calendar/connid_staleness_test.go b/internal/calendar/connid_staleness_test.go index 5264e20..484dffe 100644 --- a/internal/calendar/connid_staleness_test.go +++ b/internal/calendar/connid_staleness_test.go @@ -7,19 +7,13 @@ import ( "testing" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) // newTestDB opens a migrated in-memory DB with one user. -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - t.Cleanup(func() { database.Close() }) - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) if _, err := database.Exec( `INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) VALUES ('u1','u1@x.test','U','UTC',0,'2026-01-01T00:00:00Z')`); err != nil { @@ -28,7 +22,7 @@ func newTestDB(t *testing.T) *sql.DB { return database } -func seedConn(t *testing.T, database *sql.DB, id, userID, provider, email string, check, dest int) { +func seedConn(t *testing.T, database *db.DB, id, userID, provider, email string, check, dest int) { t.Helper() if _, err := database.Exec( `INSERT INTO calendar_connections @@ -50,7 +44,7 @@ func seedConn(t *testing.T, database *sql.DB, id, userID, provider, email string // refreshToken reproduces what a provider does on token refresh: same account, brand new // row id. -func refreshToken(t *testing.T, db *sql.DB, userID, provider, email, newID string) { +func refreshToken(t *testing.T, db *db.DB, userID, provider, email, newID string) { t.Helper() var dest, check int if err := db.QueryRow( diff --git a/internal/calendar/microsoft/microsoft.go b/internal/calendar/microsoft/microsoft.go index 6194e0a..8c1f0d3 100644 --- a/internal/calendar/microsoft/microsoft.go +++ b/internal/calendar/microsoft/microsoft.go @@ -27,6 +27,7 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/connstore" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/oauthstore" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/uid" @@ -41,14 +42,14 @@ var _ calendar.Provider = (*Client)(nil) type Client struct { config *oauth2.Config key [32]byte - db *sql.DB + db *db.DB logger *slog.Logger apiBase string // base URL for Graph API; overridable in tests } // New creates a Client. tenant defaults to "common" (any Microsoft account). // encKeyHex is the 64-char hex AES-256 encryption key. -func New(db *sql.DB, clientID, clientSecret, tenant, redirectURL, encKeyHex string) (*Client, error) { +func New(db *db.DB, clientID, clientSecret, tenant, redirectURL, encKeyHex string) (*Client, error) { b, err := hex.DecodeString(encKeyHex) if err != nil || len(b) != 32 { return nil, fmt.Errorf("microsoft: invalid encryption key") diff --git a/internal/calendar/microsoft/microsoft_test.go b/internal/calendar/microsoft/microsoft_test.go index 30088fa..53a0856 100644 --- a/internal/calendar/microsoft/microsoft_test.go +++ b/internal/calendar/microsoft/microsoft_test.go @@ -2,7 +2,6 @@ package microsoft import ( "context" - "database/sql" "encoding/base64" "io" "net/http" @@ -15,19 +14,14 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) const testKeyHex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return database } @@ -41,7 +35,7 @@ func newTestClient(t *testing.T) *Client { return c } -func seedUser(t *testing.T, database *sql.DB, userID string) { +func seedUser(t *testing.T, database *db.DB, userID string) { t.Helper() _, err := database.ExecContext(context.Background(), ` INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) diff --git a/internal/config/config.go b/internal/config/config.go index 60fb580..d94b104 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,9 @@ package config import ( + "fmt" "log/slog" + "net/url" "os" "strconv" "strings" @@ -40,6 +42,25 @@ type Config struct { ZoomClientID string ZoomClientSecret string + // SSOSharedSecret is the HMAC key for the signed session hand-off + // (GET /v1/auth/sso). Empty ⇒ that endpoint is off and 404s. Env-only and + // deliberately not settable from the admin UI: it can create users and mint + // sessions, so it belongs with the platform secrets rather than in a settings + // page an admin session can reach. + SSOSharedSecret string + + // MetricsToken is the bearer token that authorises GET /metrics. Empty ⇒ that + // endpoint 404s, so an instance never publishes its request volume, booking rate or + // queue depth by accident. Env-only, for the same reason as SSOSharedSecret. + MetricsToken string + + // STTBaseURL overrides the speech-to-text endpoint host for the notetaker, e.g. a + // regional endpoint so recordings are transcribed inside one jurisdiction. Empty ⇒ + // stt.DefaultBaseURL. Only the host is configurable; the path, model and options are + // Calnode's. Surfaced read-only in GET /v1/settings/notetaker so an operator can see + // where audio is being sent without reading the environment of a running container. + STTBaseURL string + // CookieSecure sets the Secure flag on session cookies. Defaults to true // when BASE_URL starts with https://, but can be overridden explicitly via // COOKIE_SECURE=false for HTTPS-terminated-at-proxy setups where the binary @@ -52,6 +73,26 @@ type Config struct { // the public endpoints are rate-limited regardless. Comma-separated. EmbedAllowedOrigins []string + // TrustedProxyCIDRs lists the networks whose forwarded headers are believed when + // resolving the client IP for per-IP rate limiting. Empty (the default) ⇒ the limit + // keys on the TCP peer and CF-Connecting-IP / X-Forwarded-For are ignored entirely, + // because a header from an unvetted peer is a client-chosen value. Comma-separated + // CIDRs; a bare address is taken as a single host. + TrustedProxyCIDRs []string + + // FrameAncestors lists the origins allowed to embed the admin SPA in a frame, as a + // Content-Security-Policy frame-ancestors source list. Space-separated, matching the + // CSP syntax it becomes. Empty (the default) ⇒ nothing is sent and /admin/ behaves + // exactly as it did. Each entry must be `https://host[:port]` or `'self'`; anything + // else fails Validate and the process refuses to start, because a directive the + // browser cannot parse is a directive that silently allows everything. + // + // Only the admin SPA is affected. The public booking pages keep their + // `frame-ancestors 'none'` + `X-Frame-Options: DENY` unconditionally — those are + // unauthenticated pages that take payment details, and no operator convenience is + // worth making them embeddable. + FrameAncestors []string + // DemoMode turns this instance into a public, self-resetting demo: seeds sample // data on every boot (there's no persistent volume, so every boot is a fresh DB), // disables calendar/Zoom connect, serves a disallow-all robots.txt, and exposes @@ -89,10 +130,17 @@ func Load() *Config { ZoomClientSecret: getEnv("ZOOM_CLIENT_SECRET", ""), EmbedAllowedOrigins: splitCSV(getEnv("EMBED_ALLOWED_ORIGINS", "")), + TrustedProxyCIDRs: splitCSV(getEnv("TRUSTED_PROXY_CIDRS", "")), + STTBaseURL: getEnv("STT_BASE_URL", ""), + // Space-separated, not comma: the value goes into a CSP source list verbatim, so + // it reads the same in the env var as it does in the header. + FrameAncestors: strings.Fields(getEnv("FRAME_ANCESTORS", "")), } cfg.EncryptionKey = os.Getenv("CALNODE_ENCRYPTION_KEY") cfg.RecoverySecret = os.Getenv("CALNODE_RECOVERY_SECRET") + cfg.SSOSharedSecret = os.Getenv("CALNODE_SSO_SHARED_SECRET") + cfg.MetricsToken = os.Getenv("METRICS_TOKEN") // PUBLIC_BASE_URL overrides the booker-facing host (custom/vanity domain). // Unset → inherits BASE_URL, so single-domain deploys need only set BASE_URL. cfg.PublicBaseURL = getEnv("PUBLIC_BASE_URL", cfg.BaseURL) @@ -104,6 +152,60 @@ func Load() *Config { return cfg } +// Validate reports the configuration errors an operator has to fix before the process +// can safely serve traffic. Called from main after Load; a non-nil error is fatal. +// +// It holds the settings whose wrong value is worse than their absence. A malformed CSP +// directive is the example: browsers drop a source list they cannot parse, so the admin +// UI would end up MORE embeddable than with the setting unset, and nothing in the +// response would say so. +func (c *Config) Validate() error { + for _, origin := range c.FrameAncestors { + if err := validFrameAncestor(origin); err != nil { + return fmt.Errorf("FRAME_ANCESTORS: %w", err) + } + } + return nil +} + +// validFrameAncestor accepts 'self' or an https origin with no path, credentials, query +// or fragment. +// +// Wildcards are deliberately refused even though CSP allows them. `https://*.example.com` +// trusts every host any subdomain of that name ever points at, including one taken over +// later; an operator who needs two hosts can name two hosts. Plain http is refused for +// the same reason the admin session cookie is Secure — the framing page would be able to +// read nothing, but its own compromise becomes a foothold. +func validFrameAncestor(origin string) error { + if origin == "'self'" { + return nil + } + if strings.HasPrefix(origin, "'") { + // 'none', 'unsafe-inline' and friends are keywords this setting has no use for: + // 'none' is not "unset" (see FrameAncestors) and the rest are not source + // expressions at all. Refusing them keeps the accepted grammar one line long. + return fmt.Errorf("%q is not a supported keyword; use 'self' or an https:// origin", origin) + } + u, err := url.Parse(origin) + switch { + case err != nil: + return fmt.Errorf("%q is not a URL: %w", origin, err) + case u.Scheme != "https": + return fmt.Errorf("%q must use https://", origin) + case u.Host == "": + return fmt.Errorf("%q has no host", origin) + case strings.Contains(u.Host, "*"): + return fmt.Errorf("%q must name one host, not a wildcard", origin) + case u.User != nil: + return fmt.Errorf("%q must not carry credentials", origin) + case u.Path != "" && u.Path != "/": + return fmt.Errorf("%q must be an origin, with no path", origin) + case u.RawQuery != "" || u.Fragment != "": + return fmt.Errorf("%q must be an origin, with no query or fragment", origin) + } + return nil +} + func parseLogLevel(s string) slog.Level { switch s { case "debug": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a3a9962..82be6bc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -121,3 +121,77 @@ func TestLoad_demoResetIntervalInvalidFallsBackToDefault(t *testing.T) { t.Errorf("DemoResetInterval = %v; want 30m default on invalid input", cfg.DemoResetInterval) } } + +// --------------------------------------------------------------------------- +// FRAME_ANCESTORS +// --------------------------------------------------------------------------- + +func TestLoad_frameAncestorsIsSpaceSeparated(t *testing.T) { + t.Setenv("FRAME_ANCESTORS", " https://console.example.test 'self' ") + + cfg := config.Load() + + if len(cfg.FrameAncestors) != 2 { + t.Fatalf("FrameAncestors = %#v; want 2 entries", cfg.FrameAncestors) + } + if cfg.FrameAncestors[0] != "https://console.example.test" || cfg.FrameAncestors[1] != "'self'" { + t.Errorf("FrameAncestors = %#v; want the two sources unchanged", cfg.FrameAncestors) + } + if err := cfg.Validate(); err != nil { + t.Errorf("Validate() = %v; want nil", err) + } +} + +func TestLoad_frameAncestorsDefaultsToEmpty(t *testing.T) { + os.Unsetenv("FRAME_ANCESTORS") + + cfg := config.Load() + + if len(cfg.FrameAncestors) != 0 { + t.Errorf("FrameAncestors = %#v; want empty", cfg.FrameAncestors) + } + if err := cfg.Validate(); err != nil { + t.Errorf("Validate() = %v; want nil", err) + } +} + +// A directive the browser cannot parse is dropped whole, which would leave the admin SPA +// more embeddable than with the setting unset. Refusing to start is the only outcome that +// cannot be missed. +func TestValidate_rejectsBadFrameAncestors(t *testing.T) { + cases := map[string]string{ + "plain http": "http://console.example.test", + "no scheme": "console.example.test", + "wildcard host": "https://*.example.test", + "with a path": "https://console.example.test/admin", + "with a query": "https://console.example.test?x=1", + "credentials": "https://user:pw@console.example.test", + "none keyword": "'none'", + "unsafe keyword": "'unsafe-inline'", + "scheme only": "https://", + } + for name, value := range cases { + t.Run(name, func(t *testing.T) { + t.Setenv("FRAME_ANCESTORS", value) + if err := config.Load().Validate(); err == nil { + t.Errorf("Validate() = nil for %q; want an error", value) + } + }) + } +} + +// One bad entry beside a good one still fails: a half-applied source list is a policy +// nobody wrote. +func TestValidate_rejectsAListWithOneBadEntry(t *testing.T) { + t.Setenv("FRAME_ANCESTORS", "https://good.example.test http://bad.example.test") + if err := config.Load().Validate(); err == nil { + t.Error("Validate() = nil; want an error naming the http entry") + } +} + +func TestValidate_acceptsAPortAndATrailingSlash(t *testing.T) { + t.Setenv("FRAME_ANCESTORS", "https://console.example.test:8443 https://other.example.test/") + if err := config.Load().Validate(); err != nil { + t.Errorf("Validate() = %v; want nil", err) + } +} diff --git a/internal/connstore/connstore.go b/internal/connstore/connstore.go index 1191e5d..e2e4b0e 100644 --- a/internal/connstore/connstore.go +++ b/internal/connstore/connstore.go @@ -13,7 +13,7 @@ import ( "fmt" ) -// Execer is satisfied by both *sql.DB and *sql.Tx — ResolveFlags runs inside whichever +// Execer is satisfied by both *db.DB and *db.Tx — ResolveFlags runs inside whichever // transaction the caller already opened for its own upsert. type Execer interface { QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row diff --git a/internal/connstore/connstore_test.go b/internal/connstore/connstore_test.go index fe6e3e5..bbbeed3 100644 --- a/internal/connstore/connstore_test.go +++ b/internal/connstore/connstore_test.go @@ -2,26 +2,20 @@ package connstore import ( "context" - "database/sql" "testing" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("newTestDB: open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("newTestDB: migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return database } -func seedUser(t *testing.T, database *sql.DB, userID string) { +func seedUser(t *testing.T, database *db.DB, userID string) { t.Helper() if _, err := database.ExecContext(context.Background(), ` INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) @@ -31,7 +25,7 @@ func seedUser(t *testing.T, database *sql.DB, userID string) { } } -func seedConnection(t *testing.T, database *sql.DB, userID, provider, accountEmail string, checkConflicts, isDestination int) { +func seedConnection(t *testing.T, database *db.DB, userID, provider, accountEmail string, checkConflicts, isDestination int) { t.Helper() if _, err := database.ExecContext(context.Background(), ` INSERT INTO calendar_connections diff --git a/internal/db/constraint.go b/internal/db/constraint.go new file mode 100644 index 0000000..6b44e13 --- /dev/null +++ b/internal/db/constraint.go @@ -0,0 +1,104 @@ +package db + +import ( + "errors" + "slices" + "strings" + + "github.com/jackc/pgx/v5/pgconn" + "modernc.org/sqlite" +) + +// Constraint violations are the one class of database error Calnode routinely acts +// on rather than just reporting: a duplicate slug is a 409, an out-of-range value is +// a 400, a dangling reference is a 404. Deciding which is which used to be a +// substring match on SQLite's English message, which is invisible to every gate and +// degraded silently to a 500 on PostgreSQL. +// +// Both engines are matched on their error codes. Codes rather than text because the +// text is not a contract: PostgreSQL localises its messages by the server's +// lc_messages, so a server running in German defeats any text match no matter how +// carefully written. +const ( + pgUniqueViolation = "23505" // unique_violation, and PostgreSQL's code for a primary-key collision too + pgCheckViolation = "23514" // check_violation + pgForeignKeyViolation = "23503" // foreign_key_violation +) + +// SQLite's extended result codes, as reported by (*sqlite.Error).Code(). +// +// ⛔ SQLITE_CONSTRAINT_PRIMARYKEY is a SEPARATE code from +// SQLITE_CONSTRAINT_UNIQUE even though both carry the message "UNIQUE constraint +// failed". Matching only 2067 would silently stop recognising primary-key +// collisions, and Calnode has one that matters: idempotency_keys.idempotency_key is +// a bare PRIMARY KEY, so every idempotent replay arrives as 1555. Both belong to +// IsUniqueViolation. PostgreSQL has no such split — a primary-key collision is +// 23505 like any other unique violation — which is why the trap only exists on one +// side. +const ( + sqliteConstraintCheck = 275 // SQLITE_CONSTRAINT_CHECK + sqliteConstraintForeignKey = 787 // SQLITE_CONSTRAINT_FOREIGNKEY + sqliteConstraintPrimaryKey = 1555 // SQLITE_CONSTRAINT_PRIMARYKEY + sqliteConstraintUnique = 2067 // SQLITE_CONSTRAINT_UNIQUE +) + +// SQLite's message fragments, used only as a fallback — see violates. +const ( + sqliteUniqueText = "UNIQUE constraint failed" + sqliteCheckText = "CHECK constraint failed" + sqliteForeignKeyText = "FOREIGN KEY constraint failed" +) + +// IsUniqueViolation reports whether err is a unique-constraint violation — a +// duplicate slug, a replayed idempotency key, a second booking at one host's exact +// start time. A primary-key collision counts, on both engines. +func IsUniqueViolation(err error) bool { + return violates(err, pgUniqueViolation, sqliteUniqueText, + sqliteConstraintUnique, sqliteConstraintPrimaryKey) +} + +// IsCheckViolation reports whether err is a CHECK-constraint violation, i.e. a value +// outside the set the column allows. Callers turn this into a 400, since the only way +// to reach it is a request carrying a value the handler did not validate. +func IsCheckViolation(err error) bool { + return violates(err, pgCheckViolation, sqliteCheckText, sqliteConstraintCheck) +} + +// IsForeignKeyViolation reports whether err is a foreign-key violation — a reference +// to a row that does not exist, or a delete that would orphan one. +func IsForeignKeyViolation(err error) bool { + return violates(err, pgForeignKeyViolation, sqliteForeignKeyText, sqliteConstraintForeignKey) +} + +// violates classifies err: the driver's own error code when one is available, the +// message only when it is not. +// +// A driver error is a DEFINITE answer in both directions. A *pgconn.PgError or a +// *sqlite.Error whose code does not match returns false and does not fall through to +// the text comparison — falling through would classify an error by whether its +// message happened to contain an English phrase, which is the fragility being +// removed. It would also reintroduce the primary-key trap in reverse: a 1555 error +// excluded by code would be readmitted by its "UNIQUE constraint failed" message. +// +// The text fallback is deliberate rather than vestigial. It covers an error that +// reaches here without the concrete driver type still attached — a driver release +// that changes its error type, a layer that reformats an error into a plain one +// instead of wrapping it. In that case the message is the only signal left, and +// answering from it beats answering "not a constraint violation" and returning a 500. +func violates(err error, sqlstate, sqliteText string, sqliteCodes ...int) bool { + if err == nil { + return false + } + + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == sqlstate + } + + var sqliteErr *sqlite.Error + if errors.As(err, &sqliteErr) { + return slices.Contains(sqliteCodes, sqliteErr.Code()) + } + + return strings.Contains(err.Error(), sqliteText) +} diff --git a/internal/db/constraint_test.go b/internal/db/constraint_test.go new file mode 100644 index 0000000..151c570 --- /dev/null +++ b/internal/db/constraint_test.go @@ -0,0 +1,176 @@ +package db_test + +import ( + "errors" + "testing" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" + "github.com/calnode/calnode/internal/uid" +) + +// TestConstraintPredicates provokes a real violation of each class against the real +// schema, on whichever engine dbtest is configured for, and asserts the predicate +// recognises it. +// +// Provoked rather than constructed: a hand-built error would only prove the +// predicate agrees with whatever the test author believed the driver returns, and +// the whole reason these helpers exist is that that belief was wrong on PostgreSQL. +// Run it twice — once bare, once with CALNODE_TEST_POSTGRES_DSN — and both engines +// are covered. +func TestConstraintPredicates(t *testing.T) { + h := dbtest.Open(t) + + // A user and an event type to hang the booking constraints off. + userID := uid.New() + if _, err := h.Exec( + `INSERT INTO users (id, email, name, iana_timezone) VALUES (?, ?, 'Host', 'UTC')`, + userID, userID+"@example.com"); err != nil { + t.Fatalf("seed user: %v", err) + } + etID := uid.New() + if _, err := h.Exec( + `INSERT INTO event_types (id, user_id, slug, name, duration_minutes) + VALUES (?, ?, ?, 'Call', 30)`, etID, userID, etID); err != nil { + t.Fatalf("seed event type: %v", err) + } + + t.Run("unique", func(t *testing.T) { + // users.email is UNIQUE in both migration sets. + _, err := h.Exec( + `INSERT INTO users (id, email, name, iana_timezone) VALUES (?, ?, 'Clash', 'UTC')`, + uid.New(), userID+"@example.com") + assertOnly(t, err, "unique", db.IsUniqueViolation) + }) + + // ⛔ The case that distinguishes a correct implementation from a plausible one. + // + // SQLite reports a PRIMARY KEY collision as SQLITE_CONSTRAINT_PRIMARYKEY (1555), + // NOT as SQLITE_CONSTRAINT_UNIQUE (2067) — while still saying "UNIQUE constraint + // failed" in the message. So a predicate matching only 2067 passes the subtest + // above and fails here, and the old text match passed both by accident. + // + // This is not a theoretical shape: idempotency_keys.idempotency_key is a bare + // PRIMARY KEY, so claimIdempotencyKey's entire replay path depends on 1555 being + // classified as a unique violation. PostgreSQL reports 23505 for both, so this + // subtest is redundant there — and it runs there anyway, because "redundant on + // one engine" is exactly the assumption worth re-checking after a driver bump. + t.Run("unique via primary key", func(t *testing.T) { + key := uid.New() + if _, err := h.Exec( + `INSERT INTO idempotency_keys (idempotency_key, request_hash, created_at) VALUES (?, 'h', ?)`, + key, "2026-06-01T00:00:00Z"); err != nil { + t.Fatalf("seed idempotency key: %v", err) + } + _, err := h.Exec( + `INSERT INTO idempotency_keys (idempotency_key, request_hash, created_at) VALUES (?, 'h', ?)`, + key, "2026-06-01T00:00:00Z") + assertOnly(t, err, "primary key", db.IsUniqueViolation) + }) + + t.Run("check", func(t *testing.T) { + // bookings.status has CHECK (status IN ('confirmed','cancelled')). + _, err := h.Exec(` + INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at, status, created_at, updated_at) + VALUES (?, ?, ?, '2026-06-15T09:00:00Z', '2026-06-15T09:30:00Z', 'not-a-status', ?, ?)`, + uid.New(), etID, userID, "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z") + assertOnly(t, err, "check", db.IsCheckViolation) + }) + + t.Run("foreign key", func(t *testing.T) { + // event_type_id references event_types(id). SQLite needs foreign_keys=ON, + // which OpenDB sets. + _, err := h.Exec(` + INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at, status, created_at, updated_at) + VALUES (?, 'no-such-event-type', ?, '2026-06-15T10:00:00Z', '2026-06-15T10:30:00Z', 'confirmed', ?, ?)`, + uid.New(), userID, "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z") + assertOnly(t, err, "foreign key", db.IsForeignKeyViolation) + }) + + t.Run("unrelated error", func(t *testing.T) { + // A predicate that answered true for everything would satisfy every caller + // above and be badly wrong, so the negative cases carry as much weight. + _, err := h.Exec(`SELECT * FROM a_table_that_does_not_exist`) + if err == nil { + t.Fatal("expected an error from a missing table") + } + assertNone(t, err, "missing table") + assertNone(t, errors.New("some unrelated failure"), "plain error") + assertNone(t, nil, "nil") + }) + + t.Run("unhandled constraint class", func(t *testing.T) { + // A NOT NULL violation is a constraint violation of a class nothing here + // classifies (SQLite 1299, PostgreSQL 23502). It must match none of the + // three, so a caller cannot turn it into a 409 by accident. + _, err := h.Exec( + `INSERT INTO users (id, email, name, iana_timezone) VALUES (?, ?, NULL, 'UTC')`, + uid.New(), uid.New()+"@example.com") + if err == nil { + t.Fatal("expected a NOT NULL violation") + } + assertNone(t, err, "not-null violation") + }) +} + +// TestConstraintTextFallback covers the branch no live engine reaches: an error that +// arrives without its driver type still attached. +// +// It is engine-independent, so it needs no database. The branch exists for a driver +// release that changes its error type, or a layer that reformats an error into a +// plain one rather than wrapping it — in which case the message is the only signal +// left, and answering from it beats returning a 500. Without this test the fallback +// would be unexecuted code that reads like an accident. +func TestConstraintTextFallback(t *testing.T) { + cases := []struct { + text string + want func(error) bool + name string + }{ + {"boom: UNIQUE constraint failed: t.a", db.IsUniqueViolation, "unique"}, + {"boom: CHECK constraint failed: n > 0", db.IsCheckViolation, "check"}, + {"boom: FOREIGN KEY constraint failed", db.IsForeignKeyViolation, "foreign key"}, + } + for _, c := range cases { + err := errors.New(c.text) + if !c.want(err) { + t.Errorf("%s: fallback did not recognise %q", c.name, c.text) + } + } + // And the fallback is still discriminating, not a catch-all. + assertNone(t, errors.New("NOT NULL constraint failed: t.a"), "not-null text") +} + +// assertOnly checks that want recognises err and the other two predicates do not: +// the classes have to be distinguishable, or a CHECK violation becomes a 409. +func assertOnly(t *testing.T, err error, class string, want func(error) bool) { + t.Helper() + if err == nil { + t.Fatalf("%s: expected a constraint violation, got nil", class) + } + if !want(err) { + t.Errorf("%s: predicate did not recognise %v", class, err) + } + matches := 0 + for _, p := range []func(error) bool{db.IsUniqueViolation, db.IsCheckViolation, db.IsForeignKeyViolation} { + if p(err) { + matches++ + } + } + if matches != 1 { + t.Errorf("%s: %d of 3 predicates matched %v; want exactly 1", class, matches, err) + } +} + +func assertNone(t *testing.T, err error, what string) { + t.Helper() + for name, p := range map[string]func(error) bool{ + "IsUniqueViolation": db.IsUniqueViolation, + "IsCheckViolation": db.IsCheckViolation, + "IsForeignKeyViolation": db.IsForeignKeyViolation, + } { + if p(err) { + t.Errorf("%s returned true for %s (%v)", name, what, err) + } + } +} diff --git a/internal/db/db.go b/internal/db/db.go index 21c6240..8bacdd2 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -15,15 +15,41 @@ import ( _ "modernc.org/sqlite" ) -//go:embed migrations/*.sql +//go:embed migrations/sqlite/*.sql migrations/postgres/*.sql var migrations embed.FS -// Open connects to SQLite at the given URL and configures pragmas. -// URL format: sqlite://./path/to/db or sqlite:///absolute/path or just a file path. +// Open connects to the database at the given URL and returns the bare handle. +// +// Kept for callers that have not moved to OpenDB yet; it is the same connection, +// without the dialect. Statements issued through it are not rebound, so on +// Postgres they must already use $n. func Open(databaseURL string) (*sql.DB, error) { + h, err := OpenDB(databaseURL) + if err != nil { + return nil, err + } + return h.DB, nil +} + +// OpenDB connects to the database named by databaseURL and configures the pool +// for the engine it names. +// +// URL formats: +// +// sqlite://./path/to/db, sqlite:///absolute/path, or a bare file path +// postgres://user:pass@host:port/dbname (postgresql:// is accepted too) +func OpenDB(databaseURL string) (*DB, error) { + if dialectFromURL(databaseURL) == DialectPostgres { + return openPostgres(databaseURL) + } + return openSQLite(databaseURL) +} + +// openSQLite opens SQLite and configures pragmas. +func openSQLite(databaseURL string) (*DB, error) { dsn := parseDSN(databaseURL) - db, err := sql.Open("sqlite", dsn) + db, err := sql.Open(DialectSQLite.driverName(), dsn) if err != nil { return nil, fmt.Errorf("open database: %w", err) } @@ -47,18 +73,66 @@ func Open(databaseURL string) (*sql.DB, error) { return nil, fmt.Errorf("set busy timeout: %w", err) } - return db, nil + return &DB{DB: db, dialect: DialectSQLite}, nil +} + +// openPostgres opens a PostgreSQL pool. +// +// The one-connection pool above is a SQLite constraint, not a Calnode design +// choice, and carrying it over would serialise the whole instance on a database +// that has its own concurrency control. Sizes are deliberately modest: one +// Calnode instance is one small process, and a self-hoster's Postgres is usually +// sized to match. +// +// The pool does cost one property the SQLite path gets by accident: the +// booking-overlap check (ARCHITECTURE §17) is free of TOCTOU races there only +// because every transaction queues on that single connection. Here two +// overlapping bookings can clear the check concurrently, and only +// idx_bookings_no_double — exact start times — stops them. Closing that gap +// belongs with the booking transaction (SERIALIZABLE, or a range exclusion +// constraint), not with the pool. +func openPostgres(databaseURL string) (*DB, error) { + // pgx parses the DSN here, so a malformed URL fails at Open. Reachability is + // not probed: Migrate runs immediately after Open in every entry point and + // reports an unreachable server with the same context a probe would. + db, err := sql.Open(DialectPostgres.driverName(), databaseURL) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(5) + + return &DB{DB: db, dialect: DialectPostgres}, nil +} + +// Migrate runs any pending Goose migrations embedded for this handle's dialect. +func (h *DB) Migrate() error { + return migrate(h.DB, h.dialect) } -// Migrate runs any pending Goose migrations embedded in migrations/*.sql. +// Migrate runs any pending Goose migrations embedded for db's engine, which is +// recovered from its driver. func Migrate(db *sql.DB) error { + return migrate(db, dialectOf(db)) +} + +// gooseMu guards goose's package-level dialect and base FS. A running Calnode +// only ever uses one engine, but the tests migrate both in one process and the +// two settings must not interleave. +var gooseMu sync.Mutex + +func migrate(db *sql.DB, dialect Dialect) error { + gooseMu.Lock() + defer gooseMu.Unlock() + goose.SetBaseFS(migrations) - if err := goose.SetDialect("sqlite3"); err != nil { + if err := goose.SetDialect(dialect.gooseDialect()); err != nil { return fmt.Errorf("set goose dialect: %w", err) } - if err := goose.Up(db, "migrations"); err != nil { + if err := goose.Up(db, dialect.migrationsDir()); err != nil { return fmt.Errorf("run migrations: %w", err) } @@ -73,39 +147,54 @@ var ( // TargetVersion returns the highest migration version embedded in the binary — // i.e. the schema version a fully-migrated database should report. +// +// It is dialect-independent: the per-dialect directories are two spellings of +// one schema and carry the same version numbers, which TestMigrationDirs_parity +// enforces. func TargetVersion() (int64, error) { targetVersionOnce.Do(func() { - entries, err := fs.ReadDir(migrations, "migrations") + targetVersion, targetVersionErr = maxVersion(DialectSQLite.migrationsDir()) + }) + return targetVersion, targetVersionErr +} + +// maxVersion returns the highest goose version number in an embedded migrations +// directory. +func maxVersion(dir string) (int64, error) { + entries, err := fs.ReadDir(migrations, dir) + if err != nil { + return 0, fmt.Errorf("read embedded migrations: %w", err) + } + var highest int64 + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + // Filenames are "NNNNN_description.sql"; the leading number is the version. + name := path.Base(e.Name()) + numPart, _, _ := strings.Cut(name, "_") + v, err := strconv.ParseInt(numPart, 10, 64) if err != nil { - targetVersionErr = fmt.Errorf("read embedded migrations: %w", err) - return + continue // ignore files that don't follow the goose naming convention } - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { - continue - } - // Filenames are "NNNNN_description.sql"; the leading number is the version. - name := path.Base(e.Name()) - numPart, _, _ := strings.Cut(name, "_") - v, err := strconv.ParseInt(numPart, 10, 64) - if err != nil { - continue // ignore files that don't follow the goose naming convention - } - if v > targetVersion { - targetVersion = v - } + if v > highest { + highest = v } - }) - return targetVersion, targetVersionErr + } + return highest, nil } // AppliedVersion returns the schema version currently applied to db by reading // goose's bookkeeping table directly (no goose global state). A missing // goose_db_version table returns an error, which callers treat as "not migrated". +// +// is_applied is tested for truth rather than compared to 1: goose stores it as an +// INTEGER on SQLite and a BOOLEAN on Postgres, and the bare column is the one +// spelling both engines accept. func AppliedVersion(ctx context.Context, db *sql.DB) (int64, error) { var v sql.NullInt64 err := db.QueryRowContext(ctx, - `SELECT MAX(version_id) FROM goose_db_version WHERE is_applied = 1`).Scan(&v) + `SELECT MAX(version_id) FROM goose_db_version WHERE is_applied`).Scan(&v) if err != nil { return 0, err } diff --git a/internal/db/db_test.go b/internal/db/db_test.go index e07bf0b..4bc6045 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -2,6 +2,7 @@ package db_test import ( "context" + "path/filepath" "testing" "github.com/calnode/calnode/internal/db" @@ -140,3 +141,121 @@ func TestDoubleBookingIndex_exists(t *testing.T) { t.Errorf("double-booking guard index not found: %v", err) } } + +// TestOpenDB_sqlitePragmasAndPool pins the SQLite path against accidental +// change: the single connection is a correctness guarantee (ARCHITECTURE §17), +// not a tuning choice, and the pragmas are connection-scoped so losing the +// connection loses them. A file database is used because :memory: cannot be in +// WAL mode. +func TestOpenDB_sqlitePragmasAndPool(t *testing.T) { + handle, err := db.OpenDB("sqlite://" + filepath.Join(t.TempDir(), "calnode.db")) + if err != nil { + t.Fatalf("db.OpenDB: %v", err) + } + defer handle.Close() + + if got := handle.Dialect(); got != db.DialectSQLite { + t.Errorf("dialect = %v; want %v", got, db.DialectSQLite) + } + if got := handle.Stats().MaxOpenConnections; got != 1 { + t.Errorf("MaxOpenConnections = %d; want 1", got) + } + + pragmas := []struct{ name, want string }{ + {"journal_mode", "wal"}, + {"foreign_keys", "1"}, + {"busy_timeout", "5000"}, + } + for _, p := range pragmas { + var got string + if err := handle.QueryRow(`PRAGMA ` + p.name).Scan(&got); err != nil { + t.Fatalf("PRAGMA %s: %v", p.name, err) + } + if got != p.want { + t.Errorf("PRAGMA %s = %q; want %q", p.name, got, p.want) + } + } +} + +// TestOpenDB_wrapperRoundTrip exercises the method set the rest of the codebase +// uses, on the dialect where rebinding is a no-op, so a mistake in the wrapper +// itself cannot hide behind a missing Postgres server. +func TestOpenDB_wrapperRoundTrip(t *testing.T) { + handle, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("db.OpenDB: %v", err) + } + defer handle.Close() + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + ctx := context.Background() + + if _, err := handle.ExecContext(ctx, + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + "u1", "a@example.com", "A"); err != nil { + t.Fatalf("ExecContext insert: %v", err) + } + + var name string + if err := handle.QueryRowContext(ctx, + `SELECT name FROM users WHERE id = ?`, "u1").Scan(&name); err != nil { + t.Fatalf("QueryRowContext: %v", err) + } + if name != "A" { + t.Errorf("name = %q; want %q", name, "A") + } + + rows, err := handle.QueryContext(ctx, `SELECT id FROM users WHERE email = ?`, "a@example.com") + if err != nil { + t.Fatalf("QueryContext: %v", err) + } + count := 0 + for rows.Next() { + count++ + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + rows.Close() + if count != 1 { + t.Errorf("rows returned = %d; want 1", count) + } + + tx, err := handle.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + if tx.Dialect() != handle.Dialect() { + t.Errorf("tx dialect = %v; want %v", tx.Dialect(), handle.Dialect()) + } + if _, err := tx.ExecContext(ctx, `UPDATE users SET name = ? WHERE id = ?`, "B", "u1"); err != nil { + tx.Rollback() + t.Fatalf("tx.ExecContext: %v", err) + } + if err := tx.QueryRowContext(ctx, `SELECT name FROM users WHERE id = ?`, "u1").Scan(&name); err != nil { + tx.Rollback() + t.Fatalf("tx.QueryRowContext: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("tx.Commit: %v", err) + } + if name != "B" { + t.Errorf("name after tx update = %q; want %q", name, "B") + } + + stmt, err := handle.PrepareContext(ctx, `SELECT COUNT(*) FROM users WHERE email = ?`) + if err != nil { + t.Fatalf("PrepareContext: %v", err) + } + defer stmt.Close() + var n int + if err := stmt.QueryRowContext(ctx, "a@example.com").Scan(&n); err != nil { + t.Fatalf("stmt.QueryRowContext: %v", err) + } + if n != 1 { + t.Errorf("count = %d; want 1", n) + } +} diff --git a/internal/db/dialect.go b/internal/db/dialect.go new file mode 100644 index 0000000..e3881c2 --- /dev/null +++ b/internal/db/dialect.go @@ -0,0 +1,106 @@ +package db + +import ( + "database/sql" + "strings" + + "github.com/jackc/pgx/v5/stdlib" +) + +// Dialect names the SQL engine behind a handle. +// +// Calnode's SQL is hand-written and almost entirely portable. Two things are +// not: placeholder syntax (? versus $n), which the DB/Tx wrapper hides by +// rebinding every statement on its way through, and the handful of statements +// that use engine-specific functions, which callers resolve with Dialect.SQL. +type Dialect int + +const ( + // DialectSQLite is the zero value deliberately: a handle whose dialect + // could not be determined behaves exactly as it did before this package + // knew about Postgres. + DialectSQLite Dialect = iota + DialectPostgres +) + +// String returns the dialect's canonical lower-case name. +func (d Dialect) String() string { + switch d { + case DialectPostgres: + return "postgres" + default: + return "sqlite" + } +} + +// SQL picks between two hand-written statements. +// +// Reach for this only when one portable statement is genuinely impossible — +// engine-specific functions (datetime('now'), strftime), upsert spelling, a +// PRAGMA. Differing placeholders are not a reason: the wrapper rebinds those, +// and duplicating a statement to change ? to $1 doubles the maintenance for no +// gain. +func (d Dialect) SQL(sqlite, postgres string) string { + if d == DialectPostgres { + return postgres + } + return sqlite +} + +// Rebind converts a portable ?-placeholder statement into this dialect's form. +// SQLite takes ? natively, so its statements are returned untouched with no +// allocation. +func (d Dialect) Rebind(query string) string { + if d != DialectPostgres { + return query + } + return Rebind(query) +} + +// driverName is the database/sql driver this dialect opens with. +func (d Dialect) driverName() string { + if d == DialectPostgres { + return "pgx" + } + return "sqlite" +} + +// gooseDialect is goose's name for this engine. +func (d Dialect) gooseDialect() string { + if d == DialectPostgres { + return "postgres" + } + return "sqlite3" +} + +// migrationsDir is the embedded directory holding this dialect's migrations. +// The two sets carry the same version numbers by construction — one schema, two +// spellings — which is what lets TargetVersion stay dialect-independent. +func (d Dialect) migrationsDir() string { + if d == DialectPostgres { + return "migrations/postgres" + } + return "migrations/sqlite" +} + +// dialectFromURL classifies a DATABASE_URL. Only an explicit postgres URL +// selects Postgres, so every form that worked before still works unchanged: +// sqlite://./rel, sqlite:///abs, :memory:, and a bare file path. +func dialectFromURL(databaseURL string) Dialect { + scheme := strings.ToLower(databaseURL) + if strings.HasPrefix(scheme, "postgres://") || strings.HasPrefix(scheme, "postgresql://") { + return DialectPostgres + } + return DialectSQLite +} + +// dialectOf recovers the dialect from an already-open handle, for the +// package-level helpers that still take a bare *sql.DB. An unrecognised driver +// reads as SQLite: that is what every caller of those helpers was before, so an +// unknown driver degrades to the old behaviour rather than to an error. +func dialectOf(db *sql.DB) Dialect { + if _, ok := db.Driver().(*stdlib.Driver); ok { + return DialectPostgres + } + return DialectSQLite +} diff --git a/internal/db/dialect_internal_test.go b/internal/db/dialect_internal_test.go new file mode 100644 index 0000000..19d7588 --- /dev/null +++ b/internal/db/dialect_internal_test.go @@ -0,0 +1,99 @@ +package db + +import ( + "database/sql" + "testing" +) + +func TestDialectFromURL(t *testing.T) { + tests := []struct { + url string + want Dialect + }{ + {"sqlite://./data/calnode.db", DialectSQLite}, + {"sqlite:///var/lib/calnode/calnode.db", DialectSQLite}, + {"sqlite://:memory:", DialectSQLite}, + {"sqlite://file::memory:?cache=shared&_fk=1", DialectSQLite}, + {"./data/calnode.db", DialectSQLite}, + {"/var/lib/calnode/calnode.db", DialectSQLite}, + {":memory:", DialectSQLite}, + {"", DialectSQLite}, + {"postgres://calnode@localhost:5432/calnode", DialectPostgres}, + {"postgresql://calnode@localhost:5432/calnode", DialectPostgres}, + {"postgres://u:p@h:5432/d?sslmode=require", DialectPostgres}, + {"POSTGRES://u:p@h:5432/d", DialectPostgres}, + // A path that merely mentions postgres is still a SQLite file. + {"./postgres-backup/calnode.db", DialectSQLite}, + } + + for _, tt := range tests { + if got := dialectFromURL(tt.url); got != tt.want { + t.Errorf("dialectFromURL(%q) = %v; want %v", tt.url, got, tt.want) + } + } +} + +func TestParseDSN(t *testing.T) { + tests := []struct{ url, want string }{ + {"sqlite://./data/calnode.db", "./data/calnode.db"}, + {"sqlite:///var/lib/calnode/calnode.db", "/var/lib/calnode/calnode.db"}, + {"sqlite://:memory:", ":memory:"}, + {"sqlite://file::memory:?cache=shared&_fk=1", "file::memory:?cache=shared&_fk=1"}, + {"./data/calnode.db", "./data/calnode.db"}, + // Windows: sqlite:///C:/path/db → C:/path/db. + {"sqlite:///C:/calnode/calnode.db", "C:/calnode/calnode.db"}, + } + + for _, tt := range tests { + if got := parseDSN(tt.url); got != tt.want { + t.Errorf("parseDSN(%q) = %q; want %q", tt.url, got, tt.want) + } + } +} + +// TestDialectOf covers the driver sniffing the package-level Migrate and the +// version helpers rely on. It needs no server: database/sql is lazy, so both +// handles exist without a connection. +func TestDialectOf(t *testing.T) { + sqlite, err := sql.Open(DialectSQLite.driverName(), ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer sqlite.Close() + + postgres, err := sql.Open(DialectPostgres.driverName(), "postgres://u:p@127.0.0.1:5432/d") + if err != nil { + t.Fatalf("open postgres: %v", err) + } + defer postgres.Close() + + if got := dialectOf(sqlite); got != DialectSQLite { + t.Errorf("dialectOf(sqlite handle) = %v; want %v", got, DialectSQLite) + } + if got := dialectOf(postgres); got != DialectPostgres { + t.Errorf("dialectOf(postgres handle) = %v; want %v", got, DialectPostgres) + } +} + +func TestDialectNames(t *testing.T) { + tests := []struct { + dialect Dialect + driver, goose string + migrationsPath string + }{ + {DialectSQLite, "sqlite", "sqlite3", "migrations/sqlite"}, + {DialectPostgres, "pgx", "postgres", "migrations/postgres"}, + } + + for _, tt := range tests { + if got := tt.dialect.driverName(); got != tt.driver { + t.Errorf("%v.driverName() = %q; want %q", tt.dialect, got, tt.driver) + } + if got := tt.dialect.gooseDialect(); got != tt.goose { + t.Errorf("%v.gooseDialect() = %q; want %q", tt.dialect, got, tt.goose) + } + if got := tt.dialect.migrationsDir(); got != tt.migrationsPath { + t.Errorf("%v.migrationsDir() = %q; want %q", tt.dialect, got, tt.migrationsPath) + } + } +} diff --git a/internal/db/handle.go b/internal/db/handle.go new file mode 100644 index 0000000..c8cac0c --- /dev/null +++ b/internal/db/handle.go @@ -0,0 +1,130 @@ +package db + +import ( + "context" + "database/sql" +) + +// DB is a *sql.DB that knows its dialect and rebinds placeholders. +// +// Every query method takes the portable ? form and rewrites it for the engine in +// use, so the rest of Calnode writes one statement per query no matter which +// database it runs on. Behaviour on SQLite is identical to using *sql.DB +// directly: Rebind is a no-op there. +// +// The embedded *sql.DB is exported on purpose — pool tuning, Ping, Close and +// anything that must hand a plain *sql.DB to a library (goose here, Litestream +// in DEPLOY.md) reach it as h.DB. Statements issued through that field, or +// through a *sql.Conn from the promoted Conn method, are NOT rebound; use the +// wrapper's own methods unless you are deliberately writing engine-specific SQL. +type DB struct { + *sql.DB + dialect Dialect +} + +// Dialect reports which engine this handle is talking to, for the few callers +// that must branch on it. +func (h *DB) Dialect() Dialect { return h.dialect } + +// Rebind converts a ?-placeholder statement for this handle's dialect. Useful +// when a caller builds SQL dynamically and hands it to something other than the +// methods below. +func (h *DB) Rebind(query string) string { return h.dialect.Rebind(query) } + +func (h *DB) Query(query string, args ...any) (*sql.Rows, error) { + return h.DB.Query(h.dialect.Rebind(query), args...) +} + +func (h *DB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + return h.DB.QueryContext(ctx, h.dialect.Rebind(query), args...) +} + +func (h *DB) QueryRow(query string, args ...any) *sql.Row { + return h.DB.QueryRow(h.dialect.Rebind(query), args...) +} + +func (h *DB) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { + return h.DB.QueryRowContext(ctx, h.dialect.Rebind(query), args...) +} + +func (h *DB) Exec(query string, args ...any) (sql.Result, error) { + return h.DB.Exec(h.dialect.Rebind(query), args...) +} + +func (h *DB) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + return h.DB.ExecContext(ctx, h.dialect.Rebind(query), args...) +} + +// Prepare and PrepareContext rebind at prepare time, so the returned *sql.Stmt +// needs no wrapper of its own. +func (h *DB) Prepare(query string) (*sql.Stmt, error) { + return h.DB.Prepare(h.dialect.Rebind(query)) +} + +func (h *DB) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { + return h.DB.PrepareContext(ctx, h.dialect.Rebind(query)) +} + +// Begin and BeginTx return a *Tx rather than a *sql.Tx: a transaction that +// silently stopped rebinding would be the easiest way to reintroduce ? into a +// Postgres statement. +func (h *DB) Begin() (*Tx, error) { + tx, err := h.DB.Begin() + if err != nil { + return nil, err + } + return &Tx{Tx: tx, dialect: h.dialect}, nil +} + +func (h *DB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) { + tx, err := h.DB.BeginTx(ctx, opts) + if err != nil { + return nil, err + } + return &Tx{Tx: tx, dialect: h.dialect}, nil +} + +// Tx is a *sql.Tx with the same rebinding behaviour as DB. Commit and Rollback +// are the embedded ones — they carry no SQL text. +type Tx struct { + *sql.Tx + dialect Dialect +} + +// Dialect reports which engine this transaction is running against. +func (t *Tx) Dialect() Dialect { return t.dialect } + +// Rebind converts a ?-placeholder statement for this transaction's dialect. +func (t *Tx) Rebind(query string) string { return t.dialect.Rebind(query) } + +func (t *Tx) Query(query string, args ...any) (*sql.Rows, error) { + return t.Tx.Query(t.dialect.Rebind(query), args...) +} + +func (t *Tx) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + return t.Tx.QueryContext(ctx, t.dialect.Rebind(query), args...) +} + +func (t *Tx) QueryRow(query string, args ...any) *sql.Row { + return t.Tx.QueryRow(t.dialect.Rebind(query), args...) +} + +func (t *Tx) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { + return t.Tx.QueryRowContext(ctx, t.dialect.Rebind(query), args...) +} + +func (t *Tx) Exec(query string, args ...any) (sql.Result, error) { + return t.Tx.Exec(t.dialect.Rebind(query), args...) +} + +func (t *Tx) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + return t.Tx.ExecContext(ctx, t.dialect.Rebind(query), args...) +} + +func (t *Tx) Prepare(query string) (*sql.Stmt, error) { + return t.Tx.Prepare(t.dialect.Rebind(query)) +} + +func (t *Tx) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { + return t.Tx.PrepareContext(ctx, t.dialect.Rebind(query)) +} diff --git a/internal/db/migrations/postgres/00001_initial_schema.sql b/internal/db/migrations/postgres/00001_initial_schema.sql new file mode 100644 index 0000000..1b453e0 --- /dev/null +++ b/internal/db/migrations/postgres/00001_initial_schema.sql @@ -0,0 +1,210 @@ +-- +goose Up + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + iana_timezone TEXT NOT NULL DEFAULT 'UTC', + avatar_url TEXT, + is_admin SMALLINT NOT NULL DEFAULT 0, -- first user bootstraps as admin + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, -- stored hashed; shown once on creation + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS teams ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS team_members ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'member')), + routing_priority INTEGER NOT NULL DEFAULT 0, + UNIQUE (team_id, user_id) +); + +CREATE TABLE IF NOT EXISTS event_types ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + team_id TEXT REFERENCES teams(id) ON DELETE SET NULL, + slug TEXT NOT NULL UNIQUE, -- unique within workspace (§5) + name TEXT NOT NULL, + description TEXT, + duration_minutes INTEGER NOT NULL, + slot_interval_minutes INTEGER NOT NULL DEFAULT 30, + location_type TEXT NOT NULL DEFAULT 'link' + CHECK (location_type IN ('zoom','google_meet','teams','custom_video','phone','in_person','link')), + location_value TEXT, + routing_mode TEXT NOT NULL DEFAULT 'fixed' + CHECK (routing_mode IN ('fixed', 'round_robin', 'collective', 'priority')), + buffer_before_minutes INTEGER NOT NULL DEFAULT 0, + buffer_after_minutes INTEGER NOT NULL DEFAULT 0, + min_notice_minutes INTEGER NOT NULL DEFAULT 0, + max_future_days INTEGER NOT NULL DEFAULT 60, + seat_limit INTEGER NOT NULL DEFAULT 1, + is_active SMALLINT NOT NULL DEFAULT 1, + is_public SMALLINT NOT NULL DEFAULT 1, -- false = bookable only via direct link + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS event_type_questions ( + id TEXT PRIMARY KEY, + event_type_id TEXT NOT NULL REFERENCES event_types(id) ON DELETE CASCADE, + label TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('text', 'select', 'checkbox')), + options TEXT, -- JSON array; used for type='select' + required SMALLINT NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS availability_rules ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_type_id TEXT REFERENCES event_types(id) ON DELETE CASCADE, -- NULL = global default + day_of_week INTEGER NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), -- 0=Sun … 6=Sat + start_time TEXT NOT NULL, -- HH:MM host-local wall-clock (§6.3) + end_time TEXT NOT NULL, + UNIQUE (user_id, event_type_id, day_of_week, start_time, end_time) +); + +CREATE TABLE IF NOT EXISTS availability_overrides ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + date TEXT NOT NULL, -- YYYY-MM-DD + is_available SMALLINT NOT NULL DEFAULT 0, + start_time TEXT, -- HH:MM; only when is_available=1 + end_time TEXT +); + +CREATE TABLE IF NOT EXISTS calendar_connections ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK (provider IN ('google', 'microsoft', 'caldav')), + access_token_enc TEXT NOT NULL, -- AES-GCM encrypted with CALNODE_ENCRYPTION_KEY (§15) + refresh_token_enc TEXT, + calendar_id TEXT NOT NULL, + check_conflicts SMALLINT NOT NULL DEFAULT 1, -- include in free/busy checks (§8.3) + is_destination SMALLINT NOT NULL DEFAULT 0, -- write bookings to this calendar + sync_token TEXT, -- incremental sync cursor + channel_expires_at TEXT, -- push-notification channel renewal (§13) + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS bookings ( + id TEXT PRIMARY KEY, + event_type_id TEXT NOT NULL REFERENCES event_types(id) ON DELETE RESTRICT, -- explicit: historical bookings block event-type deletion + host_id TEXT NOT NULL REFERENCES users(id), + start_at TEXT NOT NULL, -- UTC ISO 8601 (§6.3) + end_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'confirmed' + CHECK (status IN ('confirmed', 'cancelled')), + cancellation_reason TEXT, + location_value TEXT, + meeting_link TEXT, + external_event_id TEXT, -- calendar event id we created (for own-event exclusion §6.2) + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +-- Double-booking guard §6.4 — FIRST-LINE ONLY. +-- This index blocks exact-start-time collisions, but does NOT prevent overlapping bookings +-- with different start times (e.g. a 09:45 booking and a 10:00 booking on the same host). +-- The booking handler MUST also run an overlap check inside BEGIN IMMEDIATE: +-- SELECT 1 FROM bookings +-- WHERE host_id = :host_id AND status != 'cancelled' +-- AND start_at < :new_end_at AND end_at > :new_start_at +-- Fail with 409 if any row is returned before inserting. +CREATE UNIQUE INDEX IF NOT EXISTS idx_bookings_no_double + ON bookings (host_id, start_at) WHERE status != 'cancelled'; + +CREATE INDEX IF NOT EXISTS idx_bookings_host_time + ON bookings (host_id, start_at, end_at) WHERE status = 'confirmed'; + +CREATE TABLE IF NOT EXISTS booking_attendees ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + name TEXT NOT NULL, + email TEXT NOT NULL, + iana_timezone TEXT NOT NULL DEFAULT 'UTC', + is_organizer SMALLINT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS booking_answers ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + question_id TEXT NOT NULL REFERENCES event_type_questions(id), + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS webhooks ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + team_id TEXT REFERENCES teams(id) ON DELETE CASCADE, + url TEXT NOT NULL, + events TEXT NOT NULL, -- JSON array: ["booking.created","booking.cancelled",...] + secret_enc TEXT NOT NULL, -- HMAC signing secret, AES-GCM encrypted with CALNODE_ENCRYPTION_KEY (§15) + is_active SMALLINT NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id TEXT PRIMARY KEY, + webhook_id TEXT NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE, + booking_id TEXT REFERENCES bookings(id), + event TEXT NOT NULL, + payload TEXT NOT NULL, -- JSON + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'success', 'failed')), + response_status INTEGER, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_attempted_at TEXT +); + +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + payload TEXT NOT NULL, -- JSON + run_at TEXT NOT NULL, -- UTC ISO 8601; worker polls WHERE run_at <= now + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'running', 'done', 'failed')), + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 3, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE INDEX IF NOT EXISTS idx_jobs_pending + ON jobs (run_at) WHERE status = 'pending'; + +-- +goose Down + +DROP INDEX IF EXISTS idx_jobs_pending; +DROP TABLE IF EXISTS jobs; +DROP TABLE IF EXISTS webhook_deliveries; +DROP TABLE IF EXISTS webhooks; +DROP TABLE IF EXISTS booking_answers; +DROP TABLE IF EXISTS booking_attendees; +DROP INDEX IF EXISTS idx_bookings_host_time; +DROP INDEX IF EXISTS idx_bookings_no_double; +DROP TABLE IF EXISTS bookings; +DROP TABLE IF EXISTS calendar_connections; +DROP TABLE IF EXISTS availability_overrides; +DROP TABLE IF EXISTS availability_rules; +DROP TABLE IF EXISTS event_type_questions; +DROP TABLE IF EXISTS event_types; +DROP TABLE IF EXISTS team_members; +DROP TABLE IF EXISTS teams; +DROP TABLE IF EXISTS api_keys; +DROP TABLE IF EXISTS users; diff --git a/internal/db/migrations/postgres/00002_manage_tokens.sql b/internal/db/migrations/postgres/00002_manage_tokens.sql new file mode 100644 index 0000000..1a65543 --- /dev/null +++ b/internal/db/migrations/postgres/00002_manage_tokens.sql @@ -0,0 +1,14 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS booking_manage_tokens ( + token_hash TEXT PRIMARY KEY, + booking_id TEXT NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE INDEX IF NOT EXISTS idx_manage_tokens_booking + ON booking_manage_tokens (booking_id); + +-- +goose Down +DROP INDEX IF EXISTS idx_manage_tokens_booking; +DROP TABLE IF EXISTS booking_manage_tokens; diff --git a/internal/db/migrations/00003_job_lock_timeout.sql b/internal/db/migrations/postgres/00003_job_lock_timeout.sql similarity index 100% rename from internal/db/migrations/00003_job_lock_timeout.sql rename to internal/db/migrations/postgres/00003_job_lock_timeout.sql diff --git a/internal/db/migrations/postgres/00004_sessions.sql b/internal/db/migrations/postgres/00004_sessions.sql new file mode 100644 index 0000000..122a6e5 --- /dev/null +++ b/internal/db/migrations/postgres/00004_sessions.sql @@ -0,0 +1,17 @@ +-- +goose Up + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, -- 32-byte crypto-random hex; the cookie value + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); + +-- +goose Down + +DROP INDEX IF EXISTS idx_sessions_expires; +DROP INDEX IF EXISTS idx_sessions_user_id; +DROP TABLE IF EXISTS sessions; diff --git a/internal/db/migrations/00005_override_unique.sql b/internal/db/migrations/postgres/00005_override_unique.sql similarity index 100% rename from internal/db/migrations/00005_override_unique.sql rename to internal/db/migrations/postgres/00005_override_unique.sql diff --git a/internal/db/migrations/00006_jobs_type_payload_unique.sql b/internal/db/migrations/postgres/00006_jobs_type_payload_unique.sql similarity index 100% rename from internal/db/migrations/00006_jobs_type_payload_unique.sql rename to internal/db/migrations/postgres/00006_jobs_type_payload_unique.sql diff --git a/internal/db/migrations/postgres/00007_user_prefs.sql b/internal/db/migrations/postgres/00007_user_prefs.sql new file mode 100644 index 0000000..011a07e --- /dev/null +++ b/internal/db/migrations/postgres/00007_user_prefs.sql @@ -0,0 +1,12 @@ +-- +goose Up +ALTER TABLE users ADD COLUMN time_format TEXT NOT NULL DEFAULT '12h'; +ALTER TABLE users ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1; -- 1=Monday, 0=Sunday +-- The two UPDATEs are kept in step with the SQLite migration; they are no-ops +-- here, because ADD COLUMN with a NOT NULL DEFAULT backfills every existing row. +UPDATE users SET time_format = '12h' WHERE time_format IS NULL; +UPDATE users SET week_start = 1 WHERE week_start IS NULL; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00008_date_format.sql b/internal/db/migrations/postgres/00008_date_format.sql new file mode 100644 index 0000000..9b5ab47 --- /dev/null +++ b/internal/db/migrations/postgres/00008_date_format.sql @@ -0,0 +1,10 @@ +-- +goose Up +ALTER TABLE users ADD COLUMN date_format TEXT NOT NULL DEFAULT 'dmy'; +-- The UPDATE is kept in step with the SQLite migration; it is a no-op here, +-- because ADD COLUMN with a NOT NULL DEFAULT backfills every existing row. +UPDATE users SET date_format = 'dmy' WHERE date_format IS NULL; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00009_override_reason.sql b/internal/db/migrations/postgres/00009_override_reason.sql new file mode 100644 index 0000000..c3b8fab --- /dev/null +++ b/internal/db/migrations/postgres/00009_override_reason.sql @@ -0,0 +1,10 @@ +-- +goose Up +ALTER TABLE availability_overrides ADD COLUMN reason TEXT NOT NULL DEFAULT 'day_off'; +-- Back-fill existing rows: custom hours rows get 'custom_hours', unavailable rows keep 'day_off'. +UPDATE availability_overrides SET reason = 'custom_hours' WHERE is_available = 1; +UPDATE availability_overrides SET reason = 'day_off' WHERE is_available = 0; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00010_messaging_prefs.sql b/internal/db/migrations/postgres/00010_messaging_prefs.sql new file mode 100644 index 0000000..dfdcc22 --- /dev/null +++ b/internal/db/migrations/postgres/00010_messaging_prefs.sql @@ -0,0 +1,29 @@ +-- +goose Up +-- User-level notification on/off toggles (all default 1 = on, preserving existing behaviour) +ALTER TABLE users ADD COLUMN notify_confirmation SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_cancellation SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_reschedule SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_reminder SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_host_booking SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_host_cancel SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_host_reschedule SMALLINT NOT NULL DEFAULT 1; + +-- Per-event-type custom notes appended to each email type +ALTER TABLE event_types ADD COLUMN msg_confirmation TEXT; +ALTER TABLE event_types ADD COLUMN msg_cancellation TEXT; +ALTER TABLE event_types ADD COLUMN msg_reschedule TEXT; +ALTER TABLE event_types ADD COLUMN msg_reminder TEXT; + +-- Per-event-type reminder timing list (replaces the hardcoded 24h) +CREATE TABLE event_type_reminders ( + id TEXT PRIMARY KEY, + event_type_id TEXT NOT NULL REFERENCES event_types(id) ON DELETE CASCADE, + hours_before INTEGER NOT NULL CHECK(hours_before > 0), + UNIQUE(event_type_id, hours_before) +); + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. +DROP TABLE IF EXISTS event_type_reminders; diff --git a/internal/db/migrations/postgres/00011_server_settings.sql b/internal/db/migrations/postgres/00011_server_settings.sql new file mode 100644 index 0000000..c85ba6f --- /dev/null +++ b/internal/db/migrations/postgres/00011_server_settings.sql @@ -0,0 +1,20 @@ +-- +goose Up +CREATE TABLE server_settings ( + -- Not an identity column: the row is seeded below with an explicit id and the + -- CHECK makes 1 the only legal value, so a sequence would only be misleading. + id INTEGER PRIMARY KEY CHECK(id = 1), + smtp_host TEXT NOT NULL DEFAULT '', + smtp_port TEXT NOT NULL DEFAULT '587', + smtp_user TEXT NOT NULL DEFAULT '', + smtp_pass_enc TEXT NOT NULL DEFAULT '', + smtp_tls SMALLINT NOT NULL DEFAULT 0, + smtp_starttls SMALLINT NOT NULL DEFAULT 1, + email_from TEXT NOT NULL DEFAULT '', + email_from_name TEXT NOT NULL DEFAULT 'Calnode', + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')) +); +-- Seed the single row so UPDATE statements always find it. +INSERT INTO server_settings (id) VALUES (1) ON CONFLICT DO NOTHING; + +-- +goose Down +DROP TABLE IF EXISTS server_settings; diff --git a/internal/db/migrations/postgres/00012_auth_providers.sql b/internal/db/migrations/postgres/00012_auth_providers.sql new file mode 100644 index 0000000..11c19ca --- /dev/null +++ b/internal/db/migrations/postgres/00012_auth_providers.sql @@ -0,0 +1,33 @@ +-- +goose Up + +-- Email/password login and OAuth provider columns on users. +-- email_login=1 means the user can authenticate with email+password. +-- provider/provider_id store the OAuth identity (only one provider per user). +ALTER TABLE users ADD COLUMN email_login SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN password_hash TEXT; +ALTER TABLE users ADD COLUMN provider TEXT; -- 'google', 'microsoft', etc. +ALTER TABLE users ADD COLUMN provider_id TEXT; -- provider's opaque user ID + +-- Invite tokens: single-use, locked to a specific email, 7-day expiry. +CREATE TABLE invite_tokens ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT +); + +CREATE INDEX idx_invite_tokens_email ON invite_tokens(email); + +-- +goose Down + +DROP INDEX IF EXISTS idx_invite_tokens_email; +DROP TABLE IF EXISTS invite_tokens; + +-- The SQLite migration rebuilds users here because it cannot drop a column; +-- Postgres drops the four directly, which reaches the same schema. +ALTER TABLE users DROP COLUMN provider_id; +ALTER TABLE users DROP COLUMN provider; +ALTER TABLE users DROP COLUMN password_hash; +ALTER TABLE users DROP COLUMN email_login; diff --git a/internal/db/migrations/postgres/00013_google_oauth_settings.sql b/internal/db/migrations/postgres/00013_google_oauth_settings.sql new file mode 100644 index 0000000..4e89f5f --- /dev/null +++ b/internal/db/migrations/postgres/00013_google_oauth_settings.sql @@ -0,0 +1,8 @@ +-- +goose Up +ALTER TABLE server_settings ADD COLUMN google_client_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN google_client_secret_enc TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00014_booking_answers_question_cascade.sql b/internal/db/migrations/postgres/00014_booking_answers_question_cascade.sql new file mode 100644 index 0000000..119af9a --- /dev/null +++ b/internal/db/migrations/postgres/00014_booking_answers_question_cascade.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- booking_answers.question_id referenced event_type_questions(id) with no +-- ON DELETE rule, so deleting an intake question that already had responses +-- (or deleting an event type that owns such questions) failed with a foreign-key +-- violation surfaced as a 500. The SQLite migration recreates the table because it +-- cannot alter a constraint; Postgres replaces the constraint in place. +-- +-- booking_answers_question_id_fkey is the name Postgres gave the inline REFERENCES +-- in 00001: __fkey. A Postgres install can only have reached this +-- version through that migration, so the name is not a guess. +ALTER TABLE booking_answers + DROP CONSTRAINT booking_answers_question_id_fkey, + ADD CONSTRAINT booking_answers_question_id_fkey + FOREIGN KEY (question_id) REFERENCES event_type_questions(id) ON DELETE CASCADE; + +-- +goose Down +ALTER TABLE booking_answers + DROP CONSTRAINT booking_answers_question_id_fkey, + ADD CONSTRAINT booking_answers_question_id_fkey + FOREIGN KEY (question_id) REFERENCES event_type_questions(id); diff --git a/internal/db/migrations/postgres/00015_calendar_connections_expiry.sql b/internal/db/migrations/postgres/00015_calendar_connections_expiry.sql new file mode 100644 index 0000000..29c5d09 --- /dev/null +++ b/internal/db/migrations/postgres/00015_calendar_connections_expiry.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE calendar_connections ADD COLUMN expiry_at TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00016_event_types_max_active_bookings.sql b/internal/db/migrations/postgres/00016_event_types_max_active_bookings.sql new file mode 100644 index 0000000..8eeb56b --- /dev/null +++ b/internal/db/migrations/postgres/00016_event_types_max_active_bookings.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Cap how many active (upcoming, non-cancelled) bookings a single invitee may +-- hold for an event type, keyed by their email. 1 = one at a time (default); +-- 0 = unlimited. Existing rows adopt the default of 1. +ALTER TABLE event_types ADD COLUMN max_active_bookings INTEGER NOT NULL DEFAULT 1; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00017_crypto_keystore.sql b/internal/db/migrations/postgres/00017_crypto_keystore.sql new file mode 100644 index 0000000..d9253a0 --- /dev/null +++ b/internal/db/migrations/postgres/00017_crypto_keystore.sql @@ -0,0 +1,18 @@ +-- +goose Up +CREATE TABLE crypto_keystore ( + -- SQLite's INTEGER PRIMARY KEY is a rowid alias, and keyvault.go inserts + -- without an id and lets it be assigned. Identity reproduces that; BY DEFAULT + -- rather than ALWAYS so an explicit id still inserts (key recovery/rotation). + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + label TEXT NOT NULL UNIQUE, -- 'primary' | 'recovery' + wrapped_dek BYTEA NOT NULL, -- DEK encrypted under this entry's KEK + kdf TEXT NOT NULL, -- 'argon2id' + kdf_salt BYTEA NOT NULL, -- 16 random bytes + kdf_params TEXT NOT NULL, -- JSON: {"m":65536,"t":3,"p":2} + dek_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- +goose Down +DROP TABLE IF EXISTS crypto_keystore; diff --git a/internal/db/migrations/postgres/00018_user_roles.sql b/internal/db/migrations/postgres/00018_user_roles.sql new file mode 100644 index 0000000..5ec5ffa --- /dev/null +++ b/internal/db/migrations/postgres/00018_user_roles.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- Workspace roles are Member / Admin / Owner (PRD §8.10). is_admin already +-- distinguishes member vs admin; is_owner adds the single-owner tier on top. +-- Owner implies admin. Exactly one owner exists at any time (enforced in app). +ALTER TABLE users ADD COLUMN is_owner SMALLINT NOT NULL DEFAULT 0; + +-- Backfill: the bootstrap user (earliest created) becomes the owner on upgrade. +-- Fresh installs set is_owner at Setup time instead; this no-ops when empty. +UPDATE users SET is_owner = 1, is_admin = 1 +WHERE id = (SELECT id FROM users ORDER BY created_at ASC, id ASC LIMIT 1); + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00019_user_archived.sql b/internal/db/migrations/postgres/00019_user_archived.sql new file mode 100644 index 0000000..fea30d3 --- /dev/null +++ b/internal/db/migrations/postgres/00019_user_archived.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Member offboarding is archiving (soft-delete), not hard delete: the row and +-- all its links (past bookings, event types, team memberships) are preserved. +-- archived_at NULL = active; a timestamp = archived (login blocked, hidden from +-- default lists, skipped in routing). +ALTER TABLE users ADD COLUMN archived_at TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00020_user_archived_by.sql b/internal/db/migrations/postgres/00020_user_archived_by.sql new file mode 100644 index 0000000..733b5ae --- /dev/null +++ b/internal/db/migrations/postgres/00020_user_archived_by.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- Track who archived a member so restore can be gated: the owner can restore +-- anyone; an admin can restore only members they archived themselves. +ALTER TABLE users ADD COLUMN archived_by TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00021_event_type_hosts.sql b/internal/db/migrations/postgres/00021_event_type_hosts.sql new file mode 100644 index 0000000..09123ea --- /dev/null +++ b/internal/db/migrations/postgres/00021_event_type_hosts.sql @@ -0,0 +1,36 @@ +-- +goose Up +-- Routing model: an event type owns a host list. Each host has a role — +-- required (always attends), rotation (one is picked per booking), or optional +-- (joins if free). The three UI modes (Normal/Round-robin/Group) are presets +-- over these roles. See docs/teams-and-routing.md. +CREATE TABLE event_type_hosts ( + id TEXT PRIMARY KEY, + event_type_id TEXT NOT NULL REFERENCES event_types(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'required' + CHECK (role IN ('required', 'rotation', 'optional')), + priority INTEGER NOT NULL DEFAULT 0, + UNIQUE(event_type_id, user_id) +); + +CREATE INDEX idx_event_type_hosts_event ON event_type_hosts(event_type_id); + +-- Round-robin selection strategy for round_robin event types. +ALTER TABLE event_types ADD COLUMN rr_strategy TEXT NOT NULL DEFAULT 'even' + CHECK (rr_strategy IN ('even', 'soonest', 'priority')); + +-- Backfill: every existing event type gets its owner as the single required +-- host, so today's solo events become Normal with the owner as the one host and +-- host resolution is uniform from day one. +-- +-- The id matches what SQLite's lower(hex(randomblob(16))) produces — 32 lowercase +-- hex characters — using gen_random_uuid, which is core since PostgreSQL 13 and so +-- needs no extension. +INSERT INTO event_type_hosts (id, event_type_id, user_id, role, priority) +SELECT replace(gen_random_uuid()::text, '-', ''), id, user_id, 'required', 0 FROM event_types; + +-- +goose Down +DROP TABLE IF EXISTS event_type_hosts; +-- rr_strategy is deliberately left in place, mirroring the SQLite migration: +-- Postgres could drop it, but a down that lands on a different schema per engine +-- is worse than one that leaves a harmless column behind. diff --git a/internal/db/migrations/postgres/00022_booking_hosts.sql b/internal/db/migrations/postgres/00022_booking_hosts.sql new file mode 100644 index 0000000..aef70bb --- /dev/null +++ b/internal/db/migrations/postgres/00022_booking_hosts.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- Multi-host bookings: a booking can have several hosts (Group/collective, or a +-- round-robin pick plus fixed hosts). bookings.host_id stays the *primary* host +-- (for the double-book guard + back-compat); booking_hosts records everyone who +-- attends. See docs/teams-and-routing.md. +CREATE TABLE booking_hosts ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id), + is_primary SMALLINT NOT NULL DEFAULT 0, + UNIQUE(booking_id, user_id) +); + +CREATE INDEX idx_booking_hosts_booking ON booking_hosts(booking_id); +CREATE INDEX idx_booking_hosts_user ON booking_hosts(user_id); + +-- Backfill: every existing booking gets its host as the single primary host row, +-- so host resolution is uniform from day one. The id matches the shape SQLite's +-- lower(hex(randomblob(16))) produces (32 lowercase hex characters). +INSERT INTO booking_hosts (id, booking_id, user_id, is_primary) +SELECT replace(gen_random_uuid()::text, '-', ''), id, host_id, 1 FROM bookings; + +-- +goose Down +DROP TABLE IF EXISTS booking_hosts; diff --git a/internal/db/migrations/postgres/00023_booking_hosts_event_id.sql b/internal/db/migrations/postgres/00023_booking_hosts_event_id.sql new file mode 100644 index 0000000..67a58fe --- /dev/null +++ b/internal/db/migrations/postgres/00023_booking_hosts_event_id.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- Per-host external calendar event ID. Multi-host bookings (Group) create a +-- calendar event on each assigned host's calendar; we store each one here so it +-- can be moved/cancelled later. The primary host's id also lives in +-- bookings.external_event_id (kept for back-compat); this backfills its row. +ALTER TABLE booking_hosts ADD COLUMN external_event_id TEXT; + +UPDATE booking_hosts +SET external_event_id = ( + SELECT b.external_event_id FROM bookings b WHERE b.id = booking_hosts.booking_id +) +WHERE is_primary = 1; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/00024_idempotency_keys.sql b/internal/db/migrations/postgres/00024_idempotency_keys.sql similarity index 100% rename from internal/db/migrations/00024_idempotency_keys.sql rename to internal/db/migrations/postgres/00024_idempotency_keys.sql diff --git a/internal/db/migrations/postgres/00025_booking_hosts_needs_sync.sql b/internal/db/migrations/postgres/00025_booking_hosts_needs_sync.sql new file mode 100644 index 0000000..53741e6 --- /dev/null +++ b/internal/db/migrations/postgres/00025_booking_hosts_needs_sync.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- needs_sync flags a booking_hosts row whose calendar event is known to be at the +-- WRONG time: set when an inline reschedule move (gcal UpdateEvent) fails, cleared +-- when it succeeds (inline or via the reconciler). The reconciler re-applies the +-- move for flagged rows — closing the one gap the presence/absence passes can't see, +-- since drift can't be inferred from booking state alone. 0 = in sync. +ALTER TABLE booking_hosts ADD COLUMN needs_sync SMALLINT NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE booking_hosts DROP COLUMN needs_sync; diff --git a/internal/db/migrations/00026_event_type_subjects.sql b/internal/db/migrations/postgres/00026_event_type_subjects.sql similarity index 100% rename from internal/db/migrations/00026_event_type_subjects.sql rename to internal/db/migrations/postgres/00026_event_type_subjects.sql diff --git a/internal/db/migrations/00027_webhook_fields.sql b/internal/db/migrations/postgres/00027_webhook_fields.sql similarity index 100% rename from internal/db/migrations/00027_webhook_fields.sql rename to internal/db/migrations/postgres/00027_webhook_fields.sql diff --git a/internal/db/migrations/postgres/00028_tracking_settings.sql b/internal/db/migrations/postgres/00028_tracking_settings.sql new file mode 100644 index 0000000..d7e469e --- /dev/null +++ b/internal/db/migrations/postgres/00028_tracking_settings.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Tracking / analytics settings (instance-wide, on the singleton row): +-- head_html raw HTML/JS injected into the of the public booking +-- and manage pages (GTM/GA4/Pixel snippets, etc.). +-- tracking_csp_allow optional space-separated CSP source allowlist; when set, the +-- relaxed public-page CSP is tightened to just these origins. +-- datalayer_enabled push booking/cancel/reschedule events into window.dataLayer. +-- datalayer_fields JSON array of field keys to include in those pushes. +ALTER TABLE server_settings ADD COLUMN head_html TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN tracking_csp_allow TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN datalayer_enabled SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE server_settings ADD COLUMN datalayer_fields TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN head_html; +ALTER TABLE server_settings DROP COLUMN tracking_csp_allow; +ALTER TABLE server_settings DROP COLUMN datalayer_enabled; +ALTER TABLE server_settings DROP COLUMN datalayer_fields; diff --git a/internal/db/migrations/00029_branding_settings.sql b/internal/db/migrations/postgres/00029_branding_settings.sql similarity index 100% rename from internal/db/migrations/00029_branding_settings.sql rename to internal/db/migrations/postgres/00029_branding_settings.sql diff --git a/internal/db/migrations/00030_logo_height.sql b/internal/db/migrations/postgres/00030_logo_height.sql similarity index 100% rename from internal/db/migrations/00030_logo_height.sql rename to internal/db/migrations/postgres/00030_logo_height.sql diff --git a/internal/db/migrations/00031_logo_opacity.sql b/internal/db/migrations/postgres/00031_logo_opacity.sql similarity index 100% rename from internal/db/migrations/00031_logo_opacity.sql rename to internal/db/migrations/postgres/00031_logo_opacity.sql diff --git a/internal/db/migrations/00032_calendar_account_kind.sql b/internal/db/migrations/postgres/00032_calendar_account_kind.sql similarity index 100% rename from internal/db/migrations/00032_calendar_account_kind.sql rename to internal/db/migrations/postgres/00032_calendar_account_kind.sql diff --git a/internal/db/migrations/00033_oauth_mcp.sql b/internal/db/migrations/postgres/00033_oauth_mcp.sql similarity index 100% rename from internal/db/migrations/00033_oauth_mcp.sql rename to internal/db/migrations/postgres/00033_oauth_mcp.sql diff --git a/internal/db/migrations/postgres/00034_llm_settings.sql b/internal/db/migrations/postgres/00034_llm_settings.sql new file mode 100644 index 0000000..443df0a --- /dev/null +++ b/internal/db/migrations/postgres/00034_llm_settings.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- Optional LLM layer config (PRD §8.11), stored like SMTP/Google settings on the +-- single server_settings row. Provider-agnostic: any OpenAI-compatible chat-completions +-- endpoint. Off by default; api key encrypted at rest (CALNODE_ENCRYPTION_KEY). +ALTER TABLE server_settings ADD COLUMN llm_endpoint TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN llm_model TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN llm_api_key_enc TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN llm_enabled SMALLINT NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN llm_enabled; +ALTER TABLE server_settings DROP COLUMN llm_api_key_enc; +ALTER TABLE server_settings DROP COLUMN llm_model; +ALTER TABLE server_settings DROP COLUMN llm_endpoint; diff --git a/internal/db/migrations/00035_llm_instructions.sql b/internal/db/migrations/postgres/00035_llm_instructions.sql similarity index 100% rename from internal/db/migrations/00035_llm_instructions.sql rename to internal/db/migrations/postgres/00035_llm_instructions.sql diff --git a/internal/db/migrations/postgres/00036_zoom_integration.sql b/internal/db/migrations/postgres/00036_zoom_integration.sql new file mode 100644 index 0000000..82e790e --- /dev/null +++ b/internal/db/migrations/postgres/00036_zoom_integration.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- Zoom OAuth app credentials (one app per instance; each host then connects their +-- own Zoom account via OAuth). +ALTER TABLE server_settings ADD COLUMN zoom_client_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN zoom_client_secret_enc TEXT NOT NULL DEFAULT ''; + +-- Per-host Zoom OAuth tokens. Zoom is a meeting-link provider, not a calendar, so it gets +-- its own table (calendar_connections.provider has a CHECK constraint for calendar kinds). +-- One connection per user (user_id PK). +CREATE TABLE zoom_connections ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + access_token_enc TEXT NOT NULL, + refresh_token_enc TEXT NOT NULL DEFAULT '', + expiry_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')) +); + +-- The Zoom meeting id minted for a booking (used to update/delete the meeting on +-- reschedule/cancel). Empty for non-Zoom bookings or manual links. +ALTER TABLE bookings ADD COLUMN zoom_meeting_id TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00037_stripe_payments.sql b/internal/db/migrations/postgres/00037_stripe_payments.sql new file mode 100644 index 0000000..533473d --- /dev/null +++ b/internal/db/migrations/postgres/00037_stripe_payments.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- Stripe API credentials (one Stripe account per instance; admin configures in Settings). +ALTER TABLE server_settings ADD COLUMN stripe_secret_key_enc TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN stripe_publishable_key TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN stripe_webhook_secret_enc TEXT NOT NULL DEFAULT ''; + +-- Per-event-type price. 0 = free (the default; today's flow is unchanged). +ALTER TABLE event_types ADD COLUMN price_cents INTEGER NOT NULL DEFAULT 0; +ALTER TABLE event_types ADD COLUMN currency TEXT NOT NULL DEFAULT 'usd'; + +-- Payment state, separate from booking status so a paid hold can occupy the slot +-- (status='confirmed', so the double-booking guard reserves it) while still awaiting +-- payment (payment_status='pending'); side-effects are deferred until 'paid'. A new +-- booking-status value would have required a SQLite table rebuild (CHECK constraint). +-- none → free booking, no payment involved (default) +-- pending → awaiting Stripe Checkout completion (slot held) +-- paid → payment captured; confirmation side-effects have run +-- refunded → payment refunded (on cancel) +ALTER TABLE bookings ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'none'; +ALTER TABLE bookings ADD COLUMN stripe_session_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE bookings ADD COLUMN stripe_payment_intent_id TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00038_booking_amount_paid.sql b/internal/db/migrations/postgres/00038_booking_amount_paid.sql new file mode 100644 index 0000000..1201282 --- /dev/null +++ b/internal/db/migrations/postgres/00038_booking_amount_paid.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Record what was actually charged on the booking (immutable payment record), independent +-- of the event type's current price_cents which can change later. Set from the Stripe +-- Checkout session's amount_total/currency at confirmation. 0/'' for free bookings. +ALTER TABLE bookings ADD COLUMN amount_paid_cents INTEGER NOT NULL DEFAULT 0; +ALTER TABLE bookings ADD COLUMN amount_paid_currency TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00039_calendar_account_email.sql b/internal/db/migrations/postgres/00039_calendar_account_email.sql new file mode 100644 index 0000000..714719c --- /dev/null +++ b/internal/db/migrations/postgres/00039_calendar_account_email.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Identify each connected calendar account so a user can connect several (e.g. work Google +-- + personal Gmail): re-auth of the same account upserts its row, a new account inserts a new +-- row. Used for dedup + display. Existing single connections backfill to '' and keep working +-- (free/busy doesn't need the email); they get a real value on next re-connect. +ALTER TABLE calendar_connections ADD COLUMN account_email TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00040_magic_link_tokens.sql b/internal/db/migrations/postgres/00040_magic_link_tokens.sql new file mode 100644 index 0000000..b871afc --- /dev/null +++ b/internal/db/migrations/postgres/00040_magic_link_tokens.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- One-time, short-lived login links emailed to a user. We store only the SHA-256 of the +-- token (never the raw value); single-use is enforced by used_at. +CREATE TABLE magic_link_tokens ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')) +); + +-- +goose Down +DROP TABLE magic_link_tokens; diff --git a/internal/db/migrations/postgres/00041_native_analytics.sql b/internal/db/migrations/postgres/00041_native_analytics.sql new file mode 100644 index 0000000..e8ba9fd --- /dev/null +++ b/internal/db/migrations/postgres/00041_native_analytics.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Native GA4 / GTM: store just the ID; the booking page renders the official loader snippet +-- (no need to paste the whole script). Empty = that tag is off. Validated to the ID format on +-- write so the value is safe to interpolate into a script. +ALTER TABLE server_settings ADD COLUMN gtm_container_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN ga4_measurement_id TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00042_livekit.sql b/internal/db/migrations/postgres/00042_livekit.sql new file mode 100644 index 0000000..bb5d4b4 --- /dev/null +++ b/internal/db/migrations/postgres/00042_livekit.sql @@ -0,0 +1,26 @@ +-- LiveKit (self-hostable WebRTC video) as a built-in meeting location. +-- +-- Two parts: +-- 1. Instance-level config columns (server URL + API key/secret, secret encrypted) on +-- server_settings, plus a livekit_room column on bookings — like Zoom/Stripe. +-- 2. Widen the event_types.location_type CHECK to allow 'livekit'. +-- +-- The SQLite migration rebuilds event_types for part 2, and needs NO TRANSACTION plus +-- PRAGMA foreign_keys=OFF to do it without cascade-deleting the child rows. Postgres +-- replaces the constraint in place, so none of that applies: this file runs in goose's +-- transaction like every other one. event_types_location_type_check is the name Postgres +-- gave the inline CHECK in 00001 (
__check). + +-- +goose Up +ALTER TABLE server_settings ADD COLUMN livekit_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN livekit_api_key TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN livekit_api_secret_enc TEXT NOT NULL DEFAULT ''; +ALTER TABLE bookings ADD COLUMN livekit_room TEXT NOT NULL DEFAULT ''; + +ALTER TABLE event_types + DROP CONSTRAINT event_types_location_type_check, + ADD CONSTRAINT event_types_location_type_check + CHECK (location_type IN ('zoom','google_meet','teams','custom_video','phone','in_person','link','livekit')); + +-- +goose Down +-- Irreversible widening of a CHECK; the added columns are harmless. No-op down. diff --git a/internal/db/migrations/postgres/00043_livekit_recording.sql b/internal/db/migrations/postgres/00043_livekit_recording.sql new file mode 100644 index 0000000..07a6edb --- /dev/null +++ b/internal/db/migrations/postgres/00043_livekit_recording.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- Meeting recording (LiveKit Egress). Off unless enabled; recordings upload to the same +-- S3 bucket Litestream backs up to (LITESTREAM_* env), under a recordings/ prefix. +ALTER TABLE server_settings ADD COLUMN recordings_enabled SMALLINT NOT NULL DEFAULT 0; + +CREATE TABLE recordings ( + id TEXT PRIMARY KEY, + booking_id TEXT, -- derived from room "booking-"; nullable + room TEXT NOT NULL, + egress_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', -- active | complete | failed + object_key TEXT NOT NULL DEFAULT '', -- S3 key of the finished file + duration_s INTEGER NOT NULL DEFAULT 0, + started_by TEXT NOT NULL DEFAULT '', -- host participant identity + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); +CREATE INDEX idx_recordings_room ON recordings(room); +CREATE INDEX idx_recordings_egress ON recordings(egress_id); + +-- +goose Down +-- Leave the column; drop the table. +DROP TABLE IF EXISTS recordings; diff --git a/internal/db/migrations/postgres/00044_meeting_consents.sql b/internal/db/migrations/postgres/00044_meeting_consents.sql new file mode 100644 index 0000000..5818d0d --- /dev/null +++ b/internal/db/migrations/postgres/00044_meeting_consents.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- In-meeting recording consent — notice + consent-or-leave (Zoom/Teams/Meet model). This is an +-- AUDIT LOG of who acknowledged the recording notice; it does NOT gate recording (recording +-- starts on the host's click). One row per participant identity per room. +CREATE TABLE meeting_consents ( + room TEXT NOT NULL, + participant_identity TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + decision TEXT NOT NULL DEFAULT 'continue', -- continue | leave + decided_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + PRIMARY KEY (room, participant_identity) +); + +-- +goose Down +DROP TABLE IF EXISTS meeting_consents; diff --git a/internal/db/migrations/postgres/00045_notetaker.sql b/internal/db/migrations/postgres/00045_notetaker.sql new file mode 100644 index 0000000..45cd309 --- /dev/null +++ b/internal/db/migrations/postgres/00045_notetaker.sql @@ -0,0 +1,34 @@ +-- +goose Up +-- Notetaker: transcribe finished recordings (Deepgram) and summarise them (the BYO-LLM layer) +-- into notes attached to the booking. Off unless enabled + a Deepgram key is set. +ALTER TABLE server_settings ADD COLUMN notetaker_enabled SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE server_settings ADD COLUMN stt_api_key_enc TEXT NOT NULL DEFAULT ''; -- Deepgram key (encrypted) + +CREATE TABLE transcripts ( + id TEXT PRIMARY KEY, + booking_id TEXT, -- nullable; grouping key + recording_id TEXT NOT NULL, -- one transcript per recording + room TEXT NOT NULL, + text TEXT NOT NULL DEFAULT '', + segments TEXT NOT NULL DEFAULT '[]', -- JSON [{speaker,start,end,text}] + status TEXT NOT NULL DEFAULT 'complete', -- complete | failed + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); +CREATE INDEX idx_transcripts_booking ON transcripts(booking_id); +CREATE INDEX idx_transcripts_recording ON transcripts(recording_id); + +CREATE TABLE notes ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL, -- one notes doc per booking (regenerable) + content TEXT NOT NULL DEFAULT '', -- markdown summary + model TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'complete', + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); +CREATE UNIQUE INDEX idx_notes_booking ON notes(booking_id); + +-- +goose Down +DROP TABLE IF EXISTS notes; +DROP TABLE IF EXISTS transcripts; diff --git a/internal/db/migrations/00046_legal_links.sql b/internal/db/migrations/postgres/00046_legal_links.sql similarity index 100% rename from internal/db/migrations/00046_legal_links.sql rename to internal/db/migrations/postgres/00046_legal_links.sql diff --git a/internal/db/migrations/00047_event_type_archived.sql b/internal/db/migrations/postgres/00047_event_type_archived.sql similarity index 100% rename from internal/db/migrations/00047_event_type_archived.sql rename to internal/db/migrations/postgres/00047_event_type_archived.sql diff --git a/internal/db/migrations/00048_override_group.sql b/internal/db/migrations/postgres/00048_override_group.sql similarity index 100% rename from internal/db/migrations/00048_override_group.sql rename to internal/db/migrations/postgres/00048_override_group.sql diff --git a/internal/db/migrations/postgres/00049_connection_calendars.sql b/internal/db/migrations/postgres/00049_connection_calendars.sql new file mode 100644 index 0000000..394922e --- /dev/null +++ b/internal/db/migrations/postgres/00049_connection_calendars.sql @@ -0,0 +1,38 @@ +-- +goose Up +-- Per-account calendar selection. A single connected account (calendar_connections, +-- keyed by user_id+provider+account_email) can now expose several calendars, each +-- independently included in free/busy conflict checks and optionally the write target. +-- +-- Deliberately keyed by the STABLE account identity (user_id, provider, account_email), +-- NOT by calendar_connections.id: the connection row is deleted+reinserted (new id) on +-- every OAuth token refresh, so an FK to it would cascade-delete a user's calendar +-- selections on the next hourly refresh. Disconnect flows delete these rows explicitly. +CREATE TABLE IF NOT EXISTS connection_calendars ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + account_email TEXT NOT NULL DEFAULT '', + calendar_id TEXT NOT NULL, -- provider's calendar id ("primary", an address, a URL) + name TEXT NOT NULL DEFAULT '', -- display name (filled from the provider's calendar list) + check_conflicts SMALLINT NOT NULL DEFAULT 1, -- include this calendar in free/busy + is_destination SMALLINT NOT NULL DEFAULT 0, -- write new booking events here (at most one per user) + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + UNIQUE (user_id, provider, account_email, calendar_id) +); + +CREATE INDEX IF NOT EXISTS idx_connection_calendars_account + ON connection_calendars (user_id, provider, account_email); + +-- Seed from existing connections so current behaviour is preserved on upgrade: each +-- connected account's single calendar becomes one selected calendar with the same flags. +-- ON CONFLICT DO NOTHING is SQLite's INSERT OR IGNORE; the id matches the shape +-- lower(hex(randomblob(16))) produces (32 lowercase hex characters). +INSERT INTO connection_calendars + (id, user_id, provider, account_email, calendar_id, name, check_conflicts, is_destination) +SELECT replace(gen_random_uuid()::text, '-', ''), user_id, provider, COALESCE(account_email, ''), + calendar_id, '', check_conflicts, is_destination +FROM calendar_connections +ON CONFLICT DO NOTHING; + +-- +goose Down +DROP TABLE IF EXISTS connection_calendars; diff --git a/internal/db/migrations/00050_branding_banner.sql b/internal/db/migrations/postgres/00050_branding_banner.sql similarity index 100% rename from internal/db/migrations/00050_branding_banner.sql rename to internal/db/migrations/postgres/00050_branding_banner.sql diff --git a/internal/db/migrations/postgres/00051_booking_attendee_locale.sql b/internal/db/migrations/postgres/00051_booking_attendee_locale.sql new file mode 100644 index 0000000..fc7492d --- /dev/null +++ b/internal/db/migrations/postgres/00051_booking_attendee_locale.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- Captures the attendee's resolved page locale at booking time (mirrors iana_timezone) — +-- this can only be captured now, not reconstructed later, since it needs the actual +-- Accept-Language/cookie/lang= state the visitor saw when they booked. Not yet consumed by +-- emails (mailer has no i18n support yet — see internal-docs/i18n-plan.md); this just +-- captures the data so it exists once that work lands. Existing rows backfill to 'en'. +ALTER TABLE booking_attendees ADD COLUMN locale TEXT NOT NULL DEFAULT 'en'; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00052_msg_greeting.sql b/internal/db/migrations/postgres/00052_msg_greeting.sql new file mode 100644 index 0000000..35f76fa --- /dev/null +++ b/internal/db/migrations/postgres/00052_msg_greeting.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- Per-event-type override for the conversational assistant's opening greeting. Unlike +-- msg_confirmation/msg_cancellation/etc. (seeded with an English default at CreateEventType), +-- this is left NULL by default: the assistant falls back to the locale-keyed +-- "assistant_greeting" translation when unset, so translation keeps working automatically +-- for anyone who doesn't touch it. Only set (admin-authored, untranslated) once an operator +-- explicitly customizes it. +ALTER TABLE event_types ADD COLUMN msg_greeting TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00053_fallback_locale.sql b/internal/db/migrations/postgres/00053_fallback_locale.sql new file mode 100644 index 0000000..c7caff0 --- /dev/null +++ b/internal/db/migrations/postgres/00053_fallback_locale.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- What a visitor sees when their browser doesn't ask for any locale Calnode supports +-- (default English) — e.g. an operator serving a mostly Spanish-speaking customer base +-- might want Spanish instead. See internal/i18n.ResolveWithFallback. +ALTER TABLE server_settings ADD COLUMN fallback_locale TEXT NOT NULL DEFAULT 'en'; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00054_resend_api_key.sql b/internal/db/migrations/postgres/00054_resend_api_key.sql new file mode 100644 index 0000000..6c2852c --- /dev/null +++ b/internal/db/migrations/postgres/00054_resend_api_key.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- An optional Resend API key, so Calnode can deliver over Resend's HTTPS API instead of +-- SMTP. Needed because several hosting platforms (Railway below Pro, among others) block +-- outbound SMTP on their cheaper plans by dropping the packets, which is indistinguishable +-- from a misconfiguration and cannot be worked around at the SMTP layer at all. +-- +-- Encrypted at rest with the same envelope scheme as smtp_pass_enc; never returned by the +-- API, which exposes only a resend_api_key_set boolean. +-- +-- Presence of this key is what selects the transport: set means use the HTTPS API, empty +-- means fall back to SMTP. That is deliberate over probing SMTP at startup and switching +-- automatically - a probe tests reachability at boot, not at send time, and a working TCP +-- connection is not the same thing as a working delivery path. See internal/mailer/resend.go. +ALTER TABLE server_settings ADD COLUMN resend_api_key_enc TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00055_booking_hosts_calendar_id.sql b/internal/db/migrations/postgres/00055_booking_hosts_calendar_id.sql new file mode 100644 index 0000000..a7d2709 --- /dev/null +++ b/internal/db/migrations/postgres/00055_booking_hosts_calendar_id.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- Which calendar a booking's event was actually written into. +-- +-- Until now only external_event_id was stored, and reschedule/cancel re-resolved the +-- target calendar from the host's CURRENT destination. That was harmless while the +-- destination was effectively fixed at the account's default calendar, but now that a host +-- can pick any calendar inside a connected account, changing that choice would leave every +-- existing booking's event id pointing at a calendar it does not live in: the update or +-- delete resolves to the new calendar, the provider returns 404, the booking cancels in +-- Calnode, and the meeting silently stays on the host's calendar forever. +-- +-- Deliberately nullable with no backfill. Empty means "resolve the way we always did", +-- which is exactly right for rows created before this column existed - their events do live +-- in whatever the destination was and still is. Only new bookings record the calendar, so +-- the guarantee starts applying from here without rewriting history we cannot verify. +ALTER TABLE booking_hosts ADD COLUMN external_calendar_id TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/00056_bookings_list_indexes.sql b/internal/db/migrations/postgres/00056_bookings_list_indexes.sql similarity index 100% rename from internal/db/migrations/00056_bookings_list_indexes.sql rename to internal/db/migrations/postgres/00056_bookings_list_indexes.sql diff --git a/internal/db/migrations/postgres/00057_event_type_show_taken_slots.sql b/internal/db/migrations/postgres/00057_event_type_show_taken_slots.sql new file mode 100644 index 0000000..6c88e60 --- /dev/null +++ b/internal/db/migrations/postgres/00057_event_type_show_taken_slots.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Whether the booking page shows already-booked times greyed out instead of hiding +-- them. Requested in discussion #14, tracked as issue #19. +-- +-- Default 0, and that default is the point rather than caution. The slots endpoint is +-- public and unauthenticated, so turning this on makes the host's booked hours legible +-- to anyone with the link. That is a fair trade for a public-hours use case (an intro +-- call, a clinic, a tutor), where a visibly busy calendar communicates demand. It is a +-- privacy regression for an instance fronting a team's internal calendars, which is why +-- it must be chosen per event type and never inherited by surprise. +-- +-- Only starts a booking or calendar conflict removed are shown. Times outside the +-- host's working hours are never rendered, so the shape of the working day is not +-- disclosed by the grid itself. +ALTER TABLE event_types ADD COLUMN show_taken_slots SMALLINT NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE event_types DROP COLUMN show_taken_slots; diff --git a/internal/db/migrations/postgres/00058_webhook_delivery_created_at.sql b/internal/db/migrations/postgres/00058_webhook_delivery_created_at.sql new file mode 100644 index 0000000..2892396 --- /dev/null +++ b/internal/db/migrations/postgres/00058_webhook_delivery_created_at.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- webhook_deliveries had no timestamp of its own, so "the 50 most recent deliveries" +-- was expressed as ORDER BY rowid DESC. That is unportable — PostgreSQL has no rowid — +-- and it was never quite correct here either: SQLite's rowid tracks insertion order +-- only until something renumbers it, and VACUUM is allowed to. +-- +-- The default is a constant empty string rather than a timestamp expression, matching +-- the SQLite half (whose ALTER TABLE ADD COLUMN forbids a parenthesised DEFAULT) so the +-- two engines backfill existing rows identically. New rows get their value bound by the +-- writer (internal/webhook). Rows that predate this migration keep '', which sorts last +-- under ORDER BY created_at DESC — correct, since they are the oldest deliveries on the +-- instance. +ALTER TABLE webhook_deliveries ADD COLUMN created_at TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE webhook_deliveries DROP COLUMN created_at; diff --git a/internal/db/migrations/postgres/00059_sso_nonces.sql b/internal/db/migrations/postgres/00059_sso_nonces.sql new file mode 100644 index 0000000..8b36dc3 --- /dev/null +++ b/internal/db/migrations/postgres/00059_sso_nonces.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- Single-use identifiers (jti) from the signed SSO hand-off tokens. A token's jti is +-- claimed here before its session is created, so a replay of a still-valid token +-- collides on the primary key instead of being handed a second session. +-- +-- expires_at mirrors the token's own exp. The background worker purges rows past it in +-- the same GC pass that sweeps expired sessions and magic links, which is what keeps +-- this table the size of one token lifetime rather than of every sign-in ever made. +-- +-- Nothing here is engine-specific: the column types are the portable TEXT the rest of +-- the schema uses, and no DEFAULT is needed because the writer binds both values. +CREATE TABLE sso_nonces ( + jti TEXT PRIMARY KEY, + expires_at TEXT NOT NULL +); + +CREATE INDEX idx_sso_nonces_expires_at ON sso_nonces(expires_at); + +-- +goose Down +DROP INDEX IF EXISTS idx_sso_nonces_expires_at; +DROP TABLE sso_nonces; diff --git a/internal/db/migrations/00001_initial_schema.sql b/internal/db/migrations/sqlite/00001_initial_schema.sql similarity index 100% rename from internal/db/migrations/00001_initial_schema.sql rename to internal/db/migrations/sqlite/00001_initial_schema.sql diff --git a/internal/db/migrations/00002_manage_tokens.sql b/internal/db/migrations/sqlite/00002_manage_tokens.sql similarity index 100% rename from internal/db/migrations/00002_manage_tokens.sql rename to internal/db/migrations/sqlite/00002_manage_tokens.sql diff --git a/internal/db/migrations/sqlite/00003_job_lock_timeout.sql b/internal/db/migrations/sqlite/00003_job_lock_timeout.sql new file mode 100644 index 0000000..b78622d --- /dev/null +++ b/internal/db/migrations/sqlite/00003_job_lock_timeout.sql @@ -0,0 +1,14 @@ +-- +goose Up + +-- locked_until lets the worker reclaim jobs that were left in 'running' +-- state after a process crash. Set to now+N seconds on claim; the reaper +-- at the start of each Poll resets expired running jobs back to 'pending'. +ALTER TABLE jobs ADD COLUMN locked_until TEXT; + +CREATE INDEX IF NOT EXISTS idx_jobs_running_expired + ON jobs (locked_until) WHERE status = 'running'; + +-- +goose Down + +DROP INDEX IF EXISTS idx_jobs_running_expired; +ALTER TABLE jobs DROP COLUMN locked_until; diff --git a/internal/db/migrations/00004_sessions.sql b/internal/db/migrations/sqlite/00004_sessions.sql similarity index 100% rename from internal/db/migrations/00004_sessions.sql rename to internal/db/migrations/sqlite/00004_sessions.sql diff --git a/internal/db/migrations/sqlite/00005_override_unique.sql b/internal/db/migrations/sqlite/00005_override_unique.sql new file mode 100644 index 0000000..62ebd36 --- /dev/null +++ b/internal/db/migrations/sqlite/00005_override_unique.sql @@ -0,0 +1,6 @@ +-- +goose Up +CREATE UNIQUE INDEX IF NOT EXISTS idx_availability_overrides_user_date + ON availability_overrides (user_id, date); + +-- +goose Down +DROP INDEX IF EXISTS idx_availability_overrides_user_date; diff --git a/internal/db/migrations/sqlite/00006_jobs_type_payload_unique.sql b/internal/db/migrations/sqlite/00006_jobs_type_payload_unique.sql new file mode 100644 index 0000000..46a7ae2 --- /dev/null +++ b/internal/db/migrations/sqlite/00006_jobs_type_payload_unique.sql @@ -0,0 +1,9 @@ +-- +goose Up + +-- Prevent duplicate jobs of the same type+payload (e.g. two reminder.send jobs +-- for the same booking_id if enqueueReminder is called more than once). +CREATE UNIQUE INDEX IF NOT EXISTS ux_jobs_type_payload + ON jobs (type, payload); + +-- +goose Down +DROP INDEX IF EXISTS ux_jobs_type_payload; diff --git a/internal/db/migrations/00007_user_prefs.sql b/internal/db/migrations/sqlite/00007_user_prefs.sql similarity index 100% rename from internal/db/migrations/00007_user_prefs.sql rename to internal/db/migrations/sqlite/00007_user_prefs.sql diff --git a/internal/db/migrations/00008_date_format.sql b/internal/db/migrations/sqlite/00008_date_format.sql similarity index 100% rename from internal/db/migrations/00008_date_format.sql rename to internal/db/migrations/sqlite/00008_date_format.sql diff --git a/internal/db/migrations/00009_override_reason.sql b/internal/db/migrations/sqlite/00009_override_reason.sql similarity index 100% rename from internal/db/migrations/00009_override_reason.sql rename to internal/db/migrations/sqlite/00009_override_reason.sql diff --git a/internal/db/migrations/00010_messaging_prefs.sql b/internal/db/migrations/sqlite/00010_messaging_prefs.sql similarity index 100% rename from internal/db/migrations/00010_messaging_prefs.sql rename to internal/db/migrations/sqlite/00010_messaging_prefs.sql diff --git a/internal/db/migrations/00011_server_settings.sql b/internal/db/migrations/sqlite/00011_server_settings.sql similarity index 100% rename from internal/db/migrations/00011_server_settings.sql rename to internal/db/migrations/sqlite/00011_server_settings.sql diff --git a/internal/db/migrations/00012_auth_providers.sql b/internal/db/migrations/sqlite/00012_auth_providers.sql similarity index 100% rename from internal/db/migrations/00012_auth_providers.sql rename to internal/db/migrations/sqlite/00012_auth_providers.sql diff --git a/internal/db/migrations/00013_google_oauth_settings.sql b/internal/db/migrations/sqlite/00013_google_oauth_settings.sql similarity index 100% rename from internal/db/migrations/00013_google_oauth_settings.sql rename to internal/db/migrations/sqlite/00013_google_oauth_settings.sql diff --git a/internal/db/migrations/00014_booking_answers_question_cascade.sql b/internal/db/migrations/sqlite/00014_booking_answers_question_cascade.sql similarity index 100% rename from internal/db/migrations/00014_booking_answers_question_cascade.sql rename to internal/db/migrations/sqlite/00014_booking_answers_question_cascade.sql diff --git a/internal/db/migrations/00015_calendar_connections_expiry.sql b/internal/db/migrations/sqlite/00015_calendar_connections_expiry.sql similarity index 100% rename from internal/db/migrations/00015_calendar_connections_expiry.sql rename to internal/db/migrations/sqlite/00015_calendar_connections_expiry.sql diff --git a/internal/db/migrations/00016_event_types_max_active_bookings.sql b/internal/db/migrations/sqlite/00016_event_types_max_active_bookings.sql similarity index 100% rename from internal/db/migrations/00016_event_types_max_active_bookings.sql rename to internal/db/migrations/sqlite/00016_event_types_max_active_bookings.sql diff --git a/internal/db/migrations/00017_crypto_keystore.sql b/internal/db/migrations/sqlite/00017_crypto_keystore.sql similarity index 100% rename from internal/db/migrations/00017_crypto_keystore.sql rename to internal/db/migrations/sqlite/00017_crypto_keystore.sql diff --git a/internal/db/migrations/00018_user_roles.sql b/internal/db/migrations/sqlite/00018_user_roles.sql similarity index 100% rename from internal/db/migrations/00018_user_roles.sql rename to internal/db/migrations/sqlite/00018_user_roles.sql diff --git a/internal/db/migrations/00019_user_archived.sql b/internal/db/migrations/sqlite/00019_user_archived.sql similarity index 100% rename from internal/db/migrations/00019_user_archived.sql rename to internal/db/migrations/sqlite/00019_user_archived.sql diff --git a/internal/db/migrations/00020_user_archived_by.sql b/internal/db/migrations/sqlite/00020_user_archived_by.sql similarity index 100% rename from internal/db/migrations/00020_user_archived_by.sql rename to internal/db/migrations/sqlite/00020_user_archived_by.sql diff --git a/internal/db/migrations/00021_event_type_hosts.sql b/internal/db/migrations/sqlite/00021_event_type_hosts.sql similarity index 100% rename from internal/db/migrations/00021_event_type_hosts.sql rename to internal/db/migrations/sqlite/00021_event_type_hosts.sql diff --git a/internal/db/migrations/00022_booking_hosts.sql b/internal/db/migrations/sqlite/00022_booking_hosts.sql similarity index 100% rename from internal/db/migrations/00022_booking_hosts.sql rename to internal/db/migrations/sqlite/00022_booking_hosts.sql diff --git a/internal/db/migrations/00023_booking_hosts_event_id.sql b/internal/db/migrations/sqlite/00023_booking_hosts_event_id.sql similarity index 100% rename from internal/db/migrations/00023_booking_hosts_event_id.sql rename to internal/db/migrations/sqlite/00023_booking_hosts_event_id.sql diff --git a/internal/db/migrations/sqlite/00024_idempotency_keys.sql b/internal/db/migrations/sqlite/00024_idempotency_keys.sql new file mode 100644 index 0000000..42573e8 --- /dev/null +++ b/internal/db/migrations/sqlite/00024_idempotency_keys.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Idempotency keys for POST /v1/bookings. A client (e.g. an automation agent) +-- can safely retry a booking with the same Idempotency-Key header: the original +-- response is replayed verbatim instead of creating a duplicate booking. The key +-- is reserved (status_code NULL) while the original request runs; on success the +-- response is stored, on failure the row is released so a retry can proceed. +-- Rows are purged by the worker 24h after creation. +CREATE TABLE idempotency_keys ( + idempotency_key TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + status_code INTEGER, -- NULL while the original request is in flight + response_body TEXT, + booking_id TEXT, + created_at TEXT NOT NULL +); + +-- +goose Down +DROP TABLE IF EXISTS idempotency_keys; diff --git a/internal/db/migrations/00025_booking_hosts_needs_sync.sql b/internal/db/migrations/sqlite/00025_booking_hosts_needs_sync.sql similarity index 100% rename from internal/db/migrations/00025_booking_hosts_needs_sync.sql rename to internal/db/migrations/sqlite/00025_booking_hosts_needs_sync.sql diff --git a/internal/db/migrations/sqlite/00026_event_type_subjects.sql b/internal/db/migrations/sqlite/00026_event_type_subjects.sql new file mode 100644 index 0000000..4e58851 --- /dev/null +++ b/internal/db/migrations/sqlite/00026_event_type_subjects.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- Per-event-type custom email subjects for the attendee-facing emails. NULL/empty +-- means "use the built-in subject" — so existing rows and new ones default to the +-- current behaviour. Mirrors the msg_* custom-note columns. +ALTER TABLE event_types ADD COLUMN subj_confirmation TEXT; +ALTER TABLE event_types ADD COLUMN subj_cancellation TEXT; +ALTER TABLE event_types ADD COLUMN subj_reschedule TEXT; +ALTER TABLE event_types ADD COLUMN subj_reminder TEXT; + +-- +goose Down +ALTER TABLE event_types DROP COLUMN subj_confirmation; +ALTER TABLE event_types DROP COLUMN subj_cancellation; +ALTER TABLE event_types DROP COLUMN subj_reschedule; +ALTER TABLE event_types DROP COLUMN subj_reminder; diff --git a/internal/db/migrations/sqlite/00027_webhook_fields.sql b/internal/db/migrations/sqlite/00027_webhook_fields.sql new file mode 100644 index 0000000..01a7397 --- /dev/null +++ b/internal/db/migrations/sqlite/00027_webhook_fields.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- Per-webhook payload field selection: a JSON array of field keys to include in +-- the delivery's "data" object. NULL means "the default set" — the original +-- booking-metadata payload — so existing webhooks keep their exact current shape +-- (and never start emitting attendee PII or answers without being reconfigured). +ALTER TABLE webhooks ADD COLUMN fields TEXT; + +-- +goose Down +ALTER TABLE webhooks DROP COLUMN fields; diff --git a/internal/db/migrations/00028_tracking_settings.sql b/internal/db/migrations/sqlite/00028_tracking_settings.sql similarity index 100% rename from internal/db/migrations/00028_tracking_settings.sql rename to internal/db/migrations/sqlite/00028_tracking_settings.sql diff --git a/internal/db/migrations/sqlite/00029_branding_settings.sql b/internal/db/migrations/sqlite/00029_branding_settings.sql new file mode 100644 index 0000000..3445c88 --- /dev/null +++ b/internal/db/migrations/sqlite/00029_branding_settings.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- Branding (instance-wide, on the singleton row): +-- business_name display name used as the wordmark in emails and on the public +-- booking/manage pages. Falls back to "Calnode" when empty. +-- logo_url absolute https URL to a logo image, shown in the email header +-- and the public page header. Empty = text wordmark only. +ALTER TABLE server_settings ADD COLUMN business_name TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN logo_url TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN business_name; +ALTER TABLE server_settings DROP COLUMN logo_url; diff --git a/internal/db/migrations/sqlite/00030_logo_height.sql b/internal/db/migrations/sqlite/00030_logo_height.sql new file mode 100644 index 0000000..326a38b --- /dev/null +++ b/internal/db/migrations/sqlite/00030_logo_height.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- Logo display height in px (email header); public pages scale it up modestly. +-- Operator-adjustable via a 16–64px slider in Settings → Branding; 28 = sensible default. +ALTER TABLE server_settings ADD COLUMN logo_height INTEGER NOT NULL DEFAULT 28; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN logo_height; diff --git a/internal/db/migrations/sqlite/00031_logo_opacity.sql b/internal/db/migrations/sqlite/00031_logo_opacity.sql new file mode 100644 index 0000000..6a7bdb8 --- /dev/null +++ b/internal/db/migrations/sqlite/00031_logo_opacity.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- Logo opacity as a percentage (20–100); lets operators make the logo subtle. +-- Applied as CSS opacity in emails + public pages. Default 100 = fully opaque. +ALTER TABLE server_settings ADD COLUMN logo_opacity INTEGER NOT NULL DEFAULT 100; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN logo_opacity; diff --git a/internal/db/migrations/sqlite/00032_calendar_account_kind.sql b/internal/db/migrations/sqlite/00032_calendar_account_kind.sql new file mode 100644 index 0000000..0a66861 --- /dev/null +++ b/internal/db/migrations/sqlite/00032_calendar_account_kind.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Records whether a connected Microsoft calendar is a work/school account or a +-- personal Microsoft account. Personal accounts can't mint Teams-for-Business +-- links, so this gates whether a "teams" event type can auto-generate one. +-- '' = unknown (legacy rows / Google) → treated as capable; real value is +-- captured from the id_token tenant claim on (re)connect. +ALTER TABLE calendar_connections ADD COLUMN account_kind TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE calendar_connections DROP COLUMN account_kind; diff --git a/internal/db/migrations/sqlite/00033_oauth_mcp.sql b/internal/db/migrations/sqlite/00033_oauth_mcp.sql new file mode 100644 index 0000000..a17e378 --- /dev/null +++ b/internal/db/migrations/sqlite/00033_oauth_mcp.sql @@ -0,0 +1,49 @@ +-- +goose Up +-- OAuth 2.1 authorization-server tables backing the MCP "Connect" flow (PRD §11 +-- authorization). Calnode is its own AS for the /mcp resource: clients self-register +-- (dynamic client registration, public PKCE clients), the workspace owner authorizes +-- via the existing session/Google/Microsoft login + a consent screen, and bearer +-- access tokens (hashed, like api_keys) gate /mcp. All token/code values are stored as +-- SHA-256 hashes — the plaintext is only ever returned once to the client. + +CREATE TABLE oauth_clients ( + client_id TEXT PRIMARY KEY, + client_name TEXT NOT NULL DEFAULT '', + redirect_uris TEXT NOT NULL, -- JSON array of allowed redirect URIs + created_at TEXT NOT NULL +); + +CREATE TABLE oauth_auth_codes ( + code_hash TEXT PRIMARY KEY, -- SHA-256 of the authorization code + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, -- PKCE S256 challenge + scope TEXT NOT NULL DEFAULT '', + resource TEXT NOT NULL DEFAULT '', -- RFC 8707 resource indicator + expires_at TEXT NOT NULL, -- short-lived (single use) + created_at TEXT NOT NULL +); + +CREATE TABLE oauth_access_tokens ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the access token + refresh_hash TEXT UNIQUE, -- SHA-256 of the refresh token (nullable) + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT '', + resource TEXT NOT NULL DEFAULT '', + expires_at TEXT NOT NULL, -- access-token expiry + created_at TEXT NOT NULL, + last_used_at TEXT +); + +CREATE INDEX idx_oauth_tokens_user ON oauth_access_tokens(user_id); +CREATE INDEX idx_oauth_tokens_refresh ON oauth_access_tokens(refresh_hash); + +-- +goose Down +DROP INDEX idx_oauth_tokens_refresh; +DROP INDEX idx_oauth_tokens_user; +DROP TABLE oauth_access_tokens; +DROP TABLE oauth_auth_codes; +DROP TABLE oauth_clients; diff --git a/internal/db/migrations/00034_llm_settings.sql b/internal/db/migrations/sqlite/00034_llm_settings.sql similarity index 100% rename from internal/db/migrations/00034_llm_settings.sql rename to internal/db/migrations/sqlite/00034_llm_settings.sql diff --git a/internal/db/migrations/sqlite/00035_llm_instructions.sql b/internal/db/migrations/sqlite/00035_llm_instructions.sql new file mode 100644 index 0000000..1d08451 --- /dev/null +++ b/internal/db/migrations/sqlite/00035_llm_instructions.sql @@ -0,0 +1,8 @@ +-- +goose Up +-- Admin "Additional instructions" appended to the assistant's base system prompt +-- (tone, business context, do's/don'ts). The base prompt — the tool-calling contract + +-- safety rails — stays in code; this is the customization layer only. +ALTER TABLE server_settings ADD COLUMN llm_extra_instructions TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN llm_extra_instructions; diff --git a/internal/db/migrations/00036_zoom_integration.sql b/internal/db/migrations/sqlite/00036_zoom_integration.sql similarity index 100% rename from internal/db/migrations/00036_zoom_integration.sql rename to internal/db/migrations/sqlite/00036_zoom_integration.sql diff --git a/internal/db/migrations/00037_stripe_payments.sql b/internal/db/migrations/sqlite/00037_stripe_payments.sql similarity index 100% rename from internal/db/migrations/00037_stripe_payments.sql rename to internal/db/migrations/sqlite/00037_stripe_payments.sql diff --git a/internal/db/migrations/00038_booking_amount_paid.sql b/internal/db/migrations/sqlite/00038_booking_amount_paid.sql similarity index 100% rename from internal/db/migrations/00038_booking_amount_paid.sql rename to internal/db/migrations/sqlite/00038_booking_amount_paid.sql diff --git a/internal/db/migrations/00039_calendar_account_email.sql b/internal/db/migrations/sqlite/00039_calendar_account_email.sql similarity index 100% rename from internal/db/migrations/00039_calendar_account_email.sql rename to internal/db/migrations/sqlite/00039_calendar_account_email.sql diff --git a/internal/db/migrations/00040_magic_link_tokens.sql b/internal/db/migrations/sqlite/00040_magic_link_tokens.sql similarity index 100% rename from internal/db/migrations/00040_magic_link_tokens.sql rename to internal/db/migrations/sqlite/00040_magic_link_tokens.sql diff --git a/internal/db/migrations/00041_native_analytics.sql b/internal/db/migrations/sqlite/00041_native_analytics.sql similarity index 100% rename from internal/db/migrations/00041_native_analytics.sql rename to internal/db/migrations/sqlite/00041_native_analytics.sql diff --git a/internal/db/migrations/00042_livekit.sql b/internal/db/migrations/sqlite/00042_livekit.sql similarity index 100% rename from internal/db/migrations/00042_livekit.sql rename to internal/db/migrations/sqlite/00042_livekit.sql diff --git a/internal/db/migrations/00043_livekit_recording.sql b/internal/db/migrations/sqlite/00043_livekit_recording.sql similarity index 100% rename from internal/db/migrations/00043_livekit_recording.sql rename to internal/db/migrations/sqlite/00043_livekit_recording.sql diff --git a/internal/db/migrations/00044_meeting_consents.sql b/internal/db/migrations/sqlite/00044_meeting_consents.sql similarity index 100% rename from internal/db/migrations/00044_meeting_consents.sql rename to internal/db/migrations/sqlite/00044_meeting_consents.sql diff --git a/internal/db/migrations/00045_notetaker.sql b/internal/db/migrations/sqlite/00045_notetaker.sql similarity index 100% rename from internal/db/migrations/00045_notetaker.sql rename to internal/db/migrations/sqlite/00045_notetaker.sql diff --git a/internal/db/migrations/sqlite/00046_legal_links.sql b/internal/db/migrations/sqlite/00046_legal_links.sql new file mode 100644 index 0000000..5fef0ab --- /dev/null +++ b/internal/db/migrations/sqlite/00046_legal_links.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Legal links (instance-wide, on the singleton settings row): absolute URLs to the +-- operator's own Privacy Policy and Terms. Shown as links in the public booking-page +-- footer and linked from the cookie-consent banner. Empty = the link is hidden. +-- The operator is the data controller; Calnode only surfaces the links they provide. +ALTER TABLE server_settings ADD COLUMN privacy_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN terms_url TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN privacy_url; +ALTER TABLE server_settings DROP COLUMN terms_url; diff --git a/internal/db/migrations/sqlite/00047_event_type_archived.sql b/internal/db/migrations/sqlite/00047_event_type_archived.sql new file mode 100644 index 0000000..a863f8d --- /dev/null +++ b/internal/db/migrations/sqlite/00047_event_type_archived.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Archived event types: soft-hide from the default admin list without deleting the +-- row (deletion is blocked by ON DELETE RESTRICT once any booking exists). NULL = +-- not archived. Archiving also sets is_active = 0, so every existing bookability gate +-- (public booking page, the shared booking-creation core, MCP) already excludes an +-- archived type — no query needs to learn about archived_at to stay correct. Reversible. +ALTER TABLE event_types ADD COLUMN archived_at TEXT; + +-- +goose Down +ALTER TABLE event_types DROP COLUMN archived_at; diff --git a/internal/db/migrations/sqlite/00048_override_group.sql b/internal/db/migrations/sqlite/00048_override_group.sql new file mode 100644 index 0000000..e74d3f7 --- /dev/null +++ b/internal/db/migrations/sqlite/00048_override_group.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- group_id ties together the per-date rows created from a single date-range block +-- (an "out of office" span), so the UI can show and delete them as one entry. NULL +-- for single-date overrides. Slot generation is unchanged — it still reads the +-- individual per-date rows; the group is purely an admin-side convenience. +ALTER TABLE availability_overrides ADD COLUMN group_id TEXT; + +-- +goose Down +ALTER TABLE availability_overrides DROP COLUMN group_id; diff --git a/internal/db/migrations/00049_connection_calendars.sql b/internal/db/migrations/sqlite/00049_connection_calendars.sql similarity index 100% rename from internal/db/migrations/00049_connection_calendars.sql rename to internal/db/migrations/sqlite/00049_connection_calendars.sql diff --git a/internal/db/migrations/sqlite/00050_branding_banner.sql b/internal/db/migrations/sqlite/00050_branding_banner.sql new file mode 100644 index 0000000..ea8a528 --- /dev/null +++ b/internal/db/migrations/sqlite/00050_branding_banner.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Banner (instance-wide, on the singleton row): an optional full-width image +-- shown below the logo on the public booking/manage pages and in emails. +-- banner_url absolute https URL to a banner image; empty = hidden. +-- banner_opacity 20-100; CSS opacity. 100 = fully opaque. +ALTER TABLE server_settings ADD COLUMN banner_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN banner_opacity INTEGER NOT NULL DEFAULT 100; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN banner_url; +ALTER TABLE server_settings DROP COLUMN banner_opacity; diff --git a/internal/db/migrations/00051_booking_attendee_locale.sql b/internal/db/migrations/sqlite/00051_booking_attendee_locale.sql similarity index 100% rename from internal/db/migrations/00051_booking_attendee_locale.sql rename to internal/db/migrations/sqlite/00051_booking_attendee_locale.sql diff --git a/internal/db/migrations/00052_msg_greeting.sql b/internal/db/migrations/sqlite/00052_msg_greeting.sql similarity index 100% rename from internal/db/migrations/00052_msg_greeting.sql rename to internal/db/migrations/sqlite/00052_msg_greeting.sql diff --git a/internal/db/migrations/00053_fallback_locale.sql b/internal/db/migrations/sqlite/00053_fallback_locale.sql similarity index 100% rename from internal/db/migrations/00053_fallback_locale.sql rename to internal/db/migrations/sqlite/00053_fallback_locale.sql diff --git a/internal/db/migrations/00054_resend_api_key.sql b/internal/db/migrations/sqlite/00054_resend_api_key.sql similarity index 100% rename from internal/db/migrations/00054_resend_api_key.sql rename to internal/db/migrations/sqlite/00054_resend_api_key.sql diff --git a/internal/db/migrations/00055_booking_hosts_calendar_id.sql b/internal/db/migrations/sqlite/00055_booking_hosts_calendar_id.sql similarity index 100% rename from internal/db/migrations/00055_booking_hosts_calendar_id.sql rename to internal/db/migrations/sqlite/00055_booking_hosts_calendar_id.sql diff --git a/internal/db/migrations/sqlite/00056_bookings_list_indexes.sql b/internal/db/migrations/sqlite/00056_bookings_list_indexes.sql new file mode 100644 index 0000000..d07b8ad --- /dev/null +++ b/internal/db/migrations/sqlite/00056_bookings_list_indexes.sql @@ -0,0 +1,34 @@ +-- +goose Up +-- Indexes for the paginated bookings list (§9). +-- +-- The two indexes that existed both lead on host_id and are partial +-- (idx_bookings_no_double, idx_bookings_host_time), which serves the double-book +-- guard well and the list not at all. Every paged query planned as: +-- +-- SCAN bookings USING INDEX idx_bookings_no_double +-- USE TEMP B-TREE FOR ORDER BY +-- +-- i.e. sort the entire matching set to hand back 25 rows. Paginating the API without +-- this makes the response smaller but not the work: the cost still grows with every +-- booking ever made. +-- +-- With (start_at, id) the same query plans as a plain index walk and stops at the +-- LIMIT, no sort: +-- +-- SCAN bookings USING INDEX idx_bookings_start_at +-- +-- id is included as the tiebreaker the list orders by; start_at is not unique, and +-- without a second key two bookings at the same time can swap between pages and one +-- of them is never shown. +CREATE INDEX IF NOT EXISTS idx_bookings_start_at + ON bookings (start_at, id); + +-- Filtering to one event type went from a full scan to a search. The trailing +-- ORDER BY term still costs a small in-memory sort within equal start_at groups, +-- which is not worth another index. +CREATE INDEX IF NOT EXISTS idx_bookings_event_type_start + ON bookings (event_type_id, start_at, id); + +-- +goose Down +DROP INDEX IF EXISTS idx_bookings_start_at; +DROP INDEX IF EXISTS idx_bookings_event_type_start; diff --git a/internal/db/migrations/00057_event_type_show_taken_slots.sql b/internal/db/migrations/sqlite/00057_event_type_show_taken_slots.sql similarity index 100% rename from internal/db/migrations/00057_event_type_show_taken_slots.sql rename to internal/db/migrations/sqlite/00057_event_type_show_taken_slots.sql diff --git a/internal/db/migrations/sqlite/00058_webhook_delivery_created_at.sql b/internal/db/migrations/sqlite/00058_webhook_delivery_created_at.sql new file mode 100644 index 0000000..866ba46 --- /dev/null +++ b/internal/db/migrations/sqlite/00058_webhook_delivery_created_at.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- webhook_deliveries had no timestamp of its own, so "the 50 most recent deliveries" +-- was expressed as ORDER BY rowid DESC. That is unportable — PostgreSQL has no rowid — +-- and it was never quite correct here either: SQLite's rowid tracks insertion order +-- only until something renumbers it, and VACUUM is allowed to. +-- +-- The default is a constant empty string rather than a timestamp expression because +-- SQLite's ALTER TABLE ADD COLUMN forbids a parenthesised or non-deterministic DEFAULT. +-- New rows get their value bound by the writer (internal/webhook). Rows that predate +-- this migration keep '', which sorts last under ORDER BY created_at DESC — correct, +-- since they are the oldest deliveries on the instance. +ALTER TABLE webhook_deliveries ADD COLUMN created_at TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE webhook_deliveries DROP COLUMN created_at; diff --git a/internal/db/migrations/sqlite/00059_sso_nonces.sql b/internal/db/migrations/sqlite/00059_sso_nonces.sql new file mode 100644 index 0000000..0937e30 --- /dev/null +++ b/internal/db/migrations/sqlite/00059_sso_nonces.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Single-use identifiers (jti) from the signed SSO hand-off tokens. A token's jti is +-- claimed here before its session is created, so a replay of a still-valid token +-- collides on the primary key instead of being handed a second session. +-- +-- expires_at mirrors the token's own exp. The background worker purges rows past it in +-- the same GC pass that sweeps expired sessions and magic links, which is what keeps +-- this table the size of one token lifetime rather than of every sign-in ever made. +CREATE TABLE sso_nonces ( + jti TEXT PRIMARY KEY, + expires_at TEXT NOT NULL +); + +CREATE INDEX idx_sso_nonces_expires_at ON sso_nonces(expires_at); + +-- +goose Down +DROP INDEX IF EXISTS idx_sso_nonces_expires_at; +DROP TABLE sso_nonces; diff --git a/internal/db/migrations_internal_test.go b/internal/db/migrations_internal_test.go new file mode 100644 index 0000000..328b3a1 --- /dev/null +++ b/internal/db/migrations_internal_test.go @@ -0,0 +1,58 @@ +package db + +import ( + "io/fs" + "testing" +) + +// TestMigrationDirs_parity is what lets TargetVersion be dialect-independent and +// what stops the two sets drifting: a migration added for one engine and +// forgotten for the other would otherwise only show up when someone ran the other +// engine. +func TestMigrationDirs_parity(t *testing.T) { + sqliteFiles := migrationFiles(t, DialectSQLite) + postgresFiles := migrationFiles(t, DialectPostgres) + + if len(sqliteFiles) != len(postgresFiles) { + t.Fatalf("migration count differs: sqlite %d, postgres %d", len(sqliteFiles), len(postgresFiles)) + } + + for i, name := range sqliteFiles { + if postgresFiles[i] != name { + t.Errorf("migration %d differs: sqlite %q, postgres %q", i, name, postgresFiles[i]) + } + } + + sqliteTarget, err := maxVersion(DialectSQLite.migrationsDir()) + if err != nil { + t.Fatalf("maxVersion(sqlite): %v", err) + } + postgresTarget, err := maxVersion(DialectPostgres.migrationsDir()) + if err != nil { + t.Fatalf("maxVersion(postgres): %v", err) + } + if sqliteTarget != postgresTarget { + t.Errorf("target version differs: sqlite %d, postgres %d", sqliteTarget, postgresTarget) + } + if int(sqliteTarget) != len(sqliteFiles) { + t.Errorf("target version %d does not match file count %d — a gap or a duplicate number", + sqliteTarget, len(sqliteFiles)) + } +} + +// migrationFiles lists a dialect's embedded migrations, sorted (fs.ReadDir sorts +// by name, which for NNNNN_ prefixes is version order). +func migrationFiles(t *testing.T, dialect Dialect) []string { + t.Helper() + + entries, err := fs.ReadDir(migrations, dialect.migrationsDir()) + if err != nil { + t.Fatalf("read %s: %v", dialect.migrationsDir(), err) + } + + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return names +} diff --git a/internal/db/postgres_test.go b/internal/db/postgres_test.go new file mode 100644 index 0000000..4e75e69 --- /dev/null +++ b/internal/db/postgres_test.go @@ -0,0 +1,622 @@ +package db_test + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "net/url" + "os" + "regexp" + "slices" + "testing" + + "github.com/calnode/calnode/internal/db" +) + +// The PostgreSQL tests need a server, so they are opt-in: with +// CALNODE_TEST_POSTGRES_DSN unset they skip, and upstream CI and anyone running +// the SQLite build see no change. Point it at a database you are happy for a test +// to create and drop schemas in, e.g. +// +// CALNODE_TEST_POSTGRES_DSN=postgres://postgres:pw@127.0.0.1:5432/calnode?sslmode=disable +const postgresDSNEnv = "CALNODE_TEST_POSTGRES_DSN" + +// knownMigrationCount is the version a fully-migrated database must report. It is a +// sanity check on the embedded set, not a property of PostgreSQL, so it moves with +// every migration added — one named constant rather than the same literal in two +// assertions, so adding one is a single edit that cannot be half-done. +const knownMigrationCount = 59 + +// openTestPostgres returns a handle scoped to a schema of its own, created for +// this test and dropped when it finishes. A schema rather than a database because +// CREATE DATABASE cannot run inside a transaction and cannot be reached over the +// same connection, and a private schema is enough: goose_db_version and every +// table land inside it, so tests neither collide nor leave anything behind. +func openTestPostgres(t *testing.T) *db.DB { + t.Helper() + + dsn := os.Getenv(postgresDSNEnv) + if dsn == "" { + t.Skipf("%s not set; skipping PostgreSQL tests", postgresDSNEnv) + } + + admin, err := db.OpenDB(dsn) + if err != nil { + t.Fatalf("db.OpenDB(admin): %v", err) + } + defer admin.Close() + + // Random hex, not a test name: this is interpolated into DDL, and an + // identifier we generated byte by byte cannot carry a quote or a keyword. + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + t.Fatalf("rand: %v", err) + } + schema := "calnode_test_" + hex.EncodeToString(buf) + + if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil { + t.Fatalf("create schema %s: %v", schema, err) + } + + handle, err := db.OpenDB(withSearchPath(t, dsn, schema)) + if err != nil { + t.Fatalf("db.OpenDB(%s): %v", schema, err) + } + + t.Cleanup(func() { + handle.Close() + + cleanup, err := db.OpenDB(dsn) + if err != nil { + t.Errorf("reopen to drop schema %s: %v", schema, err) + return + } + defer cleanup.Close() + if _, err := cleanup.Exec(`DROP SCHEMA ` + schema + ` CASCADE`); err != nil { + t.Errorf("drop schema %s: %v", schema, err) + } + }) + + return handle +} + +// withSearchPath adds search_path to the DSN. pgx forwards unrecognised URL +// parameters as server runtime parameters, so this pins every session on the +// handle to the test's schema without a per-connection hook. +func withSearchPath(t *testing.T, dsn, schema string) string { + t.Helper() + + u, err := url.Parse(dsn) + if err != nil { + t.Fatalf("parse %s: %v", postgresDSNEnv, err) + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + return u.String() +} + +func TestPostgres_openDialectAndPool(t *testing.T) { + handle := openTestPostgres(t) + + if got := handle.Dialect(); got != db.DialectPostgres { + t.Errorf("dialect = %v; want %v", got, db.DialectPostgres) + } + // The SQLite single-connection rule must not have followed us here. + if got := handle.Stats().MaxOpenConnections; got != 10 { + t.Errorf("MaxOpenConnections = %d; want 10", got) + } + if err := handle.Ping(); err != nil { + t.Fatalf("Ping: %v", err) + } +} + +func TestPostgres_migrateToTargetVersion(t *testing.T) { + handle := openTestPostgres(t) + ctx := context.Background() + + // Before migrating, the goose bookkeeping table is absent → not ready. + if ready, _ := db.SchemaReady(ctx, handle.DB); ready { + t.Error("SchemaReady = true before migrations ran; want false") + } + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + target, err := db.TargetVersion() + if err != nil { + t.Fatalf("TargetVersion: %v", err) + } + applied, err := db.AppliedVersion(ctx, handle.DB) + if err != nil { + t.Fatalf("AppliedVersion: %v", err) + } + if applied != target { + t.Errorf("applied version = %d; want target %d", applied, target) + } + if target != knownMigrationCount { + t.Errorf("target version = %d; want %d (sanity check against the known migration set)", target, knownMigrationCount) + } + + ready, err := db.SchemaReady(ctx, handle.DB) + if err != nil { + t.Fatalf("SchemaReady after migrate: %v", err) + } + if !ready { + t.Error("SchemaReady = false after migrations ran; want true") + } +} + +func TestPostgres_migrateIdempotent(t *testing.T) { + handle := openTestPostgres(t) + + for range 2 { + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + } +} + +// TestPostgres_migrateViaPackageFunc covers the compatibility path: Migrate on a +// bare *sql.DB has to recover the dialect from the driver, and getting that wrong +// would run the SQLite migrations against Postgres. +func TestPostgres_migrateViaPackageFunc(t *testing.T) { + handle := openTestPostgres(t) + + if err := db.Migrate(handle.DB); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + + applied, err := db.AppliedVersion(context.Background(), handle.DB) + if err != nil { + t.Fatalf("AppliedVersion: %v", err) + } + if applied != knownMigrationCount { + t.Errorf("applied version = %d; want %d", applied, knownMigrationCount) + } +} + +func TestPostgres_tablesExist(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + tables := []string{ + "users", "api_keys", "teams", "team_members", + "event_types", "event_type_questions", + "availability_rules", "availability_overrides", + "calendar_connections", + "bookings", "booking_attendees", "booking_answers", + "webhooks", "webhook_deliveries", + "jobs", + // Tables added by later migrations, so this also proves the whole set ran. + "server_settings", "crypto_keystore", "event_type_hosts", "booking_hosts", + "idempotency_keys", "oauth_access_tokens", "zoom_connections", + "magic_link_tokens", "recordings", "meeting_consents", "transcripts", + "notes", "connection_calendars", + } + + for _, table := range tables { + var name string + err := handle.QueryRow( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = current_schema() AND table_name = ?`, table, + ).Scan(&name) + if err != nil { + t.Errorf("table %q not found after migration: %v", table, err) + } + } +} + +// TestPostgres_doubleBookingIndex checks the partial unique index carried over as +// a partial index. Without the WHERE clause it would block a re-book of a +// cancelled slot, which is the behaviour the SQLite index deliberately avoids. +func TestPostgres_doubleBookingIndex(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + var indexdef string + err := handle.QueryRow( + `SELECT indexdef FROM pg_indexes + WHERE schemaname = current_schema() AND indexname = 'idx_bookings_no_double'`, + ).Scan(&indexdef) + if err != nil { + t.Fatalf("double-booking guard index not found: %v", err) + } + + for _, want := range []string{"UNIQUE", "host_id", "start_at", "WHERE"} { + if !regexp.MustCompile(want).MatchString(indexdef) { + t.Errorf("index definition %q is missing %q", indexdef, want) + } + } +} + +// TestPostgres_flagColumnsStayIntegers is the load-bearing type check: the 0/1 +// flag columns are read into Go ints all over the codebase, so a translation that +// turned them into BOOLEAN would break every one of those scans. +func TestPostgres_flagColumnsStayIntegers(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + flags := []struct{ table, column string }{ + {"users", "is_admin"}, + {"users", "is_owner"}, + {"users", "email_login"}, + {"users", "notify_confirmation"}, + {"event_types", "is_active"}, + {"event_types", "is_public"}, + {"event_types", "show_taken_slots"}, + {"event_type_questions", "required"}, + {"availability_overrides", "is_available"}, + {"calendar_connections", "check_conflicts"}, + {"calendar_connections", "is_destination"}, + {"connection_calendars", "check_conflicts"}, + {"booking_attendees", "is_organizer"}, + {"booking_hosts", "is_primary"}, + {"booking_hosts", "needs_sync"}, + {"server_settings", "smtp_tls"}, + {"server_settings", "smtp_starttls"}, + {"server_settings", "llm_enabled"}, + {"server_settings", "recordings_enabled"}, + {"server_settings", "notetaker_enabled"}, + } + + for _, f := range flags { + var dataType string + err := handle.QueryRow( + `SELECT data_type FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?`, + f.table, f.column, + ).Scan(&dataType) + if err != nil { + t.Errorf("%s.%s: %v", f.table, f.column, err) + continue + } + if dataType != "smallint" && dataType != "integer" { + t.Errorf("%s.%s is %q; want an integer type (763 call sites scan these into Go ints)", + f.table, f.column, dataType) + } + } + + // Prove the scan, not just the catalogue: server_settings is seeded by 00011. + var smtpTLS, smtpStartTLS int + if err := handle.QueryRow( + `SELECT smtp_tls, smtp_starttls FROM server_settings WHERE id = ?`, 1, + ).Scan(&smtpTLS, &smtpStartTLS); err != nil { + t.Fatalf("scan flag columns into int: %v", err) + } + if smtpTLS != 0 || smtpStartTLS != 1 { + t.Errorf("seeded flags = (%d, %d); want (0, 1)", smtpTLS, smtpStartTLS) + } +} + +// TestPostgres_timestampDefaultsMatchSQLite pins the format of the defaulted +// timestamp columns. They stay TEXT holding the same strings SQLite writes, +// because every time column in this schema is compared and sorted as a string. +func TestPostgres_timestampDefaultsMatchSQLite(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + // users.created_at defaults to SQLite's strftime('%Y-%m-%dT%H:%M:%fZ','now'). + if _, err := handle.Exec( + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + "u1", "a@example.com", "A"); err != nil { + t.Fatalf("insert user: %v", err) + } + + var createdAt string + if err := handle.QueryRow(`SELECT created_at FROM users WHERE id = ?`, "u1").Scan(&createdAt); err != nil { + t.Fatalf("read created_at: %v", err) + } + const isoMillis = `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$` + if !regexp.MustCompile(isoMillis).MatchString(createdAt) { + t.Errorf("users.created_at = %q; want SQLite's %s form", createdAt, isoMillis) + } + + // server_settings.updated_at defaults to SQLite's datetime('now'). + var updatedAt string + if err := handle.QueryRow(`SELECT updated_at FROM server_settings WHERE id = ?`, 1).Scan(&updatedAt); err != nil { + t.Fatalf("read updated_at: %v", err) + } + const secondsUTC = `^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$` + if !regexp.MustCompile(secondsUTC).MatchString(updatedAt) { + t.Errorf("server_settings.updated_at = %q; want SQLite's %s form", updatedAt, secondsUTC) + } +} + +// TestPostgres_identityPrimaryKey covers the one column that relied on SQLite's +// rowid: keyvault.go inserts a keystore entry without an id. +func TestPostgres_identityPrimaryKey(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + if _, err := handle.Exec(` + INSERT INTO crypto_keystore + (label, wrapped_dek, kdf, kdf_salt, kdf_params, dek_version, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 1, ?, ?)`, + "primary", []byte("wrapped"), "argon2id", []byte("salt"), `{"m":65536,"t":3,"p":2}`, + "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z"); err != nil { + t.Fatalf("insert keystore entry without an id: %v", err) + } + + var id int64 + var wrapped []byte + if err := handle.QueryRow( + `SELECT id, wrapped_dek FROM crypto_keystore WHERE label = ?`, "primary", + ).Scan(&id, &wrapped); err != nil { + t.Fatalf("read keystore entry: %v", err) + } + if id == 0 { + t.Error("id was not assigned; the identity column is missing") + } + if string(wrapped) != "wrapped" { + t.Errorf("wrapped_dek = %q; want %q (BYTEA round trip)", wrapped, "wrapped") + } +} + +// TestPostgres_wrapperRebinds is the end-to-end proof that the wrapper is what +// makes ?-placeholder SQL work here: the same statement through the bare handle +// must fail. +func TestPostgres_wrapperRebinds(t *testing.T) { + handle := openTestPostgres(t) + ctx := context.Background() + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + if _, err := handle.ExecContext(ctx, + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + "u1", "a@example.com", "A"); err != nil { + t.Fatalf("ExecContext through the wrapper: %v", err) + } + + if _, err := handle.DB.ExecContext(ctx, + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + "u2", "b@example.com", "B"); err == nil { + t.Error("? placeholders succeeded on the bare *sql.DB; the rebinding test proves nothing") + } + + var name string + if err := handle.QueryRowContext(ctx, + `SELECT name FROM users WHERE email = ? AND is_admin = ?`, "a@example.com", 0).Scan(&name); err != nil { + t.Fatalf("QueryRowContext: %v", err) + } + if name != "A" { + t.Errorf("name = %q; want %q", name, "A") + } + + rows, err := handle.QueryContext(ctx, `SELECT id FROM users WHERE email = ?`, "a@example.com") + if err != nil { + t.Fatalf("QueryContext: %v", err) + } + count := 0 + for rows.Next() { + count++ + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + rows.Close() + if count != 1 { + t.Errorf("rows returned = %d; want 1", count) + } + + tx, err := handle.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE users SET name = ? WHERE id = ?`, "B", "u1"); err != nil { + tx.Rollback() + t.Fatalf("tx.ExecContext: %v", err) + } + if err := tx.QueryRowContext(ctx, `SELECT name FROM users WHERE id = ?`, "u1").Scan(&name); err != nil { + tx.Rollback() + t.Fatalf("tx.QueryRowContext: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("tx.Commit: %v", err) + } + if name != "B" { + t.Errorf("name after tx update = %q; want %q", name, "B") + } + + stmt, err := handle.PrepareContext(ctx, `SELECT COUNT(*) FROM users WHERE email = ?`) + if err != nil { + t.Fatalf("PrepareContext: %v", err) + } + defer stmt.Close() + var n int + if err := stmt.QueryRowContext(ctx, "a@example.com").Scan(&n); err != nil { + t.Fatalf("stmt.QueryRowContext: %v", err) + } + if n != 1 { + t.Errorf("count = %d; want 1", n) + } +} + +// TestPostgres_partialUniqueIndexEnforced exercises the double-booking guard +// itself rather than its definition: the index is the only thing standing between +// two concurrent bookings and a double-booked host now that Postgres has a +// connection pool (see openPostgres). +func TestPostgres_partialUniqueIndexEnforced(t *testing.T) { + handle := openTestPostgres(t) + ctx := context.Background() + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + seed := []struct { + query string + args []any + }{ + {`INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, []any{"h1", "h@example.com", "Host"}}, + {`INSERT INTO event_types (id, user_id, slug, name, duration_minutes) VALUES (?, ?, ?, ?, ?)`, + []any{"e1", "h1", "intro", "Intro", 30}}, + {`INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at) VALUES (?, ?, ?, ?, ?)`, + []any{"b1", "e1", "h1", "2026-01-01T10:00:00.000Z", "2026-01-01T10:30:00.000Z"}}, + } + for _, s := range seed { + if _, err := handle.ExecContext(ctx, s.query, s.args...); err != nil { + t.Fatalf("seed %q: %v", s.query, err) + } + } + + // Same host, same start: refused. + if _, err := handle.ExecContext(ctx, + `INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at) VALUES (?, ?, ?, ?, ?)`, + "b2", "e1", "h1", "2026-01-01T10:00:00.000Z", "2026-01-01T10:30:00.000Z"); err == nil { + t.Error("second booking at the same start time was accepted; the guard index is not enforcing") + } + + // Cancelling the first frees the slot — the point of the WHERE clause. + if _, err := handle.ExecContext(ctx, + `UPDATE bookings SET status = 'cancelled' WHERE id = ?`, "b1"); err != nil { + t.Fatalf("cancel booking: %v", err) + } + if _, err := handle.ExecContext(ctx, + `INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at) VALUES (?, ?, ?, ?, ?)`, + "b3", "e1", "h1", "2026-01-01T10:00:00.000Z", "2026-01-01T10:30:00.000Z"); err != nil { + t.Errorf("re-booking a cancelled slot was refused: %v", err) + } +} + +// TestPostgres_schemaMatchesSQLite migrates both engines and compares the result, +// table by table and column by column. It is the check the translation actually +// needs: every other test here says a specific thing survived, and this one says +// nothing was quietly dropped, renamed or added along the way. +// +// Names only, not types: TEXT versus text and SMALLINT versus integer are the +// translation working as intended, and the types that do matter are pinned by +// TestPostgres_flagColumnsStayIntegers. +func TestPostgres_schemaMatchesSQLite(t *testing.T) { + postgres := openTestPostgres(t) + if err := postgres.Migrate(); err != nil { + t.Fatalf("Migrate(postgres): %v", err) + } + + sqlite, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("db.OpenDB(sqlite): %v", err) + } + defer sqlite.Close() + if err := sqlite.Migrate(); err != nil { + t.Fatalf("Migrate(sqlite): %v", err) + } + + sqliteSchema := sqliteColumns(t, sqlite) + postgresSchema := postgresColumns(t, postgres) + + // Guard against a vacuous pass: an empty map on either side would satisfy + // every comparison below. + if len(sqliteSchema) < 30 { + t.Fatalf("only %d tables found on SQLite; the comparison would prove nothing", len(sqliteSchema)) + } + + for table, want := range sqliteSchema { + got, ok := postgresSchema[table] + if !ok { + t.Errorf("table %q exists on SQLite and not on Postgres", table) + continue + } + if !slices.Equal(want, got) { + t.Errorf("table %q columns differ\n sqlite: %v\n pgsql: %v", table, want, got) + } + } + for table := range postgresSchema { + if _, ok := sqliteSchema[table]; !ok { + t.Errorf("table %q exists on Postgres and not on SQLite", table) + } + } +} + +// sqliteColumns maps table name to sorted column names, skipping SQLite's own +// bookkeeping tables (sqlite_sequence appears because goose's version table uses +// AUTOINCREMENT). +func sqliteColumns(t *testing.T, handle *db.DB) map[string][]string { + t.Helper() + + rows, err := handle.Query( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`) + if err != nil { + t.Fatalf("list sqlite tables: %v", err) + } + tables := scanStrings(t, rows) + + schema := make(map[string][]string, len(tables)) + for _, table := range tables { + // PRAGMA table_info takes no placeholder; the name comes from + // sqlite_master, not from input. + rows, err := handle.Query(`SELECT name FROM pragma_table_info('` + table + `')`) + if err != nil { + t.Fatalf("columns of %q: %v", table, err) + } + cols := scanStrings(t, rows) + slices.Sort(cols) + schema[table] = cols + } + return schema +} + +// postgresColumns maps table name to sorted column names for the test schema. +func postgresColumns(t *testing.T, handle *db.DB) map[string][]string { + t.Helper() + + rows, err := handle.Query( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' + ORDER BY table_name`) + if err != nil { + t.Fatalf("list postgres tables: %v", err) + } + tables := scanStrings(t, rows) + + schema := make(map[string][]string, len(tables)) + for _, table := range tables { + rows, err := handle.Query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = ?`, table) + if err != nil { + t.Fatalf("columns of %q: %v", table, err) + } + cols := scanStrings(t, rows) + slices.Sort(cols) + schema[table] = cols + } + return schema +} + +func scanStrings(t *testing.T, rows *sql.Rows) []string { + t.Helper() + defer rows.Close() + + var out []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + t.Fatalf("scan: %v", err) + } + out = append(out, s) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + return out +} diff --git a/internal/db/rebind.go b/internal/db/rebind.go new file mode 100644 index 0000000..f0d5639 --- /dev/null +++ b/internal/db/rebind.go @@ -0,0 +1,113 @@ +package db + +import ( + "strconv" + "strings" +) + +// Rebind rewrites the ? placeholders in query to PostgreSQL's $1…$n form, +// numbering them left to right so the caller's argument order is unchanged. +// +// It is a small lexer rather than a string replace because a ? inside a string +// literal, a quoted identifier or a comment is data, not a placeholder. +// Renumbering one of those corrupts the statement, and the corruption surfaces +// at runtime — as a wrong result or a confusing type error — a long way from the +// code that caused it. +// +// Not recognised, because Calnode's SQL contains none of them and guessing would +// be worse than saying so: PostgreSQL escape strings (E'...', where a backslash +// escapes the closing quote), dollar-quoted bodies ($tag$…$tag$), and +// MSSQL-style [bracketed] identifiers. Statements using those must be written per +// dialect with Dialect.SQL and their own $n. +func Rebind(query string) string { + // Overwhelmingly the common case for DDL and for already-converted SQL. + if !strings.Contains(query, "?") { + return query + } + + var b strings.Builder + b.Grow(len(query) + 8) + + n := 0 + for i := 0; i < len(query); { + switch c := query[i]; { + case c == '\'' || c == '"' || c == '`': + // A quoted run is copied verbatim: single quotes are string + // literals, and both double quotes and backticks are identifier + // quotes SQLite accepts. + end := endOfQuoted(query, i) + b.WriteString(query[i:end]) + i = end + case c == '-' && i+1 < len(query) && query[i+1] == '-': + end := endOfLineComment(query, i) + b.WriteString(query[i:end]) + i = end + case c == '/' && i+1 < len(query) && query[i+1] == '*': + end := endOfBlockComment(query, i) + b.WriteString(query[i:end]) + i = end + case c == '?': + n++ + b.WriteByte('$') + b.WriteString(strconv.Itoa(n)) + i++ + default: + b.WriteByte(c) + i++ + } + } + + return b.String() +} + +// endOfQuoted returns the index just past the quoted run starting at i, where +// query[i] is the opening quote. A doubled quote is an escaped quote and does not +// close the run — that is SQL's only escape under both engines' default settings. +// An unterminated run extends to the end of the string, which hands the malformed +// statement to the engine to reject instead of mangling the remainder. +func endOfQuoted(query string, i int) int { + quote := query[i] + for j := i + 1; j < len(query); j++ { + if query[j] != quote { + continue + } + if j+1 < len(query) && query[j+1] == quote { + j++ // skip the escaped quote; the loop's j++ skips its partner + continue + } + return j + 1 + } + return len(query) +} + +// endOfLineComment returns the index of the newline ending the -- comment at i, +// so the newline itself is copied by the caller and line structure survives. +func endOfLineComment(query string, i int) int { + if k := strings.IndexByte(query[i:], '\n'); k >= 0 { + return i + k + } + return len(query) +} + +// endOfBlockComment returns the index just past the /* */ comment at i. Nesting +// is honoured: PostgreSQL nests block comments, so treating the first */ as the +// end would drop back into "SQL" while still inside a comment. +func endOfBlockComment(query string, i int) int { + depth := 0 + for j := i; j+1 < len(query); { + switch { + case query[j] == '/' && query[j+1] == '*': + depth++ + j += 2 + case query[j] == '*' && query[j+1] == '/': + depth-- + j += 2 + if depth == 0 { + return j + } + default: + j++ + } + } + return len(query) +} diff --git a/internal/db/rebind_test.go b/internal/db/rebind_test.go new file mode 100644 index 0000000..e469f61 --- /dev/null +++ b/internal/db/rebind_test.go @@ -0,0 +1,147 @@ +package db_test + +import ( + "strings" + "testing" + + "github.com/calnode/calnode/internal/db" +) + +func TestRebind(t *testing.T) { + tests := []struct { + name string + query string + want string + }{{ + name: "no placeholders", + query: `SELECT id FROM users`, + want: `SELECT id FROM users`, + }, { + name: "one placeholder", + query: `SELECT id FROM users WHERE email = ?`, + want: `SELECT id FROM users WHERE email = $1`, + }, { + name: "numbered left to right", + query: `UPDATE users SET name = ?, email = ? WHERE id = ?`, + want: `UPDATE users SET name = $1, email = $2 WHERE id = $3`, + }, { + name: "string literal holding a question mark", + query: `SELECT id FROM event_types WHERE name = 'why?' AND slug = ?`, + want: `SELECT id FROM event_types WHERE name = 'why?' AND slug = $1`, + }, { + name: "escaped quote inside a literal does not end it", + query: `SELECT ? WHERE reason = 'it''s a ?' AND id = ?`, + want: `SELECT $1 WHERE reason = 'it''s a ?' AND id = $2`, + }, { + name: "double-quoted identifier", + query: `SELECT "odd?column" FROM t WHERE id = ?`, + want: `SELECT "odd?column" FROM t WHERE id = $1`, + }, { + name: "escaped double quote inside an identifier", + query: `SELECT "a""?b" FROM t WHERE id = ?`, + want: `SELECT "a""?b" FROM t WHERE id = $1`, + }, { + name: "backtick identifier", + query: "SELECT `weird?` FROM t WHERE id = ?", + want: "SELECT `weird?` FROM t WHERE id = $1", + }, { + name: "line comment", + query: "SELECT id -- what about ?\nFROM t WHERE id = ?", + want: "SELECT id -- what about ?\nFROM t WHERE id = $1", + }, { + name: "line comment at end of input", + query: `SELECT id FROM t WHERE id = ? -- trailing ?`, + want: `SELECT id FROM t WHERE id = $1 -- trailing ?`, + }, { + name: "block comment", + query: `SELECT /* ? not a param ? */ id FROM t WHERE id = ?`, + want: `SELECT /* ? not a param ? */ id FROM t WHERE id = $1`, + }, { + name: "nested block comment", + query: `SELECT /* outer /* inner ? */ still ? */ id FROM t WHERE id = ?`, + want: `SELECT /* outer /* inner ? */ still ? */ id FROM t WHERE id = $1`, + }, { + name: "division is not a comment", + query: `SELECT price_cents / 100 FROM event_types WHERE id = ?`, + want: `SELECT price_cents / 100 FROM event_types WHERE id = $1`, + }, { + name: "negative number is not a comment", + query: `SELECT id FROM t WHERE n = -1 AND id = ?`, + want: `SELECT id FROM t WHERE n = -1 AND id = $1`, + }, { + name: "unterminated literal swallows the rest", + query: `SELECT id FROM t WHERE s = 'oops ? and id = ?`, + want: `SELECT id FROM t WHERE s = 'oops ? and id = ?`, + }, { + name: "unterminated block comment swallows the rest", + query: `SELECT id FROM t /* oops ? and id = ?`, + want: `SELECT id FROM t /* oops ? and id = ?`, + }, { + name: "already rebound is left alone", + query: `SELECT id FROM t WHERE id = $1`, + want: `SELECT id FROM t WHERE id = $1`, + }, { + name: "multi-line statement with a comment block", + query: "-- +goose Up\nINSERT INTO t (a, b) VALUES (?, ?)\n -- ? in a comment\n ON CONFLICT DO NOTHING", + want: "-- +goose Up\nINSERT INTO t (a, b) VALUES ($1, $2)\n -- ? in a comment\n ON CONFLICT DO NOTHING", + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := db.Rebind(tt.query); got != tt.want { + t.Errorf("Rebind(%q)\n got %q\nwant %q", tt.query, got, tt.want) + } + }) + } +} + +// TestRebind_manyPlaceholders covers the two-digit boundary: a naive +// implementation that scanned for "$1" or replaced in a fixed order would go +// wrong at ten. +func TestRebind_manyPlaceholders(t *testing.T) { + const n = 12 + query := "INSERT INTO t VALUES (" + strings.Repeat("?, ", n-1) + "?)" + + got := db.Rebind(query) + + want := "INSERT INTO t VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)" + if got != want { + t.Errorf("Rebind(%q)\n got %q\nwant %q", query, got, want) + } +} + +func TestDialectRebind(t *testing.T) { + const query = `SELECT id FROM users WHERE email = ? AND is_admin = ?` + + if got := db.DialectSQLite.Rebind(query); got != query { + t.Errorf("DialectSQLite.Rebind rewrote the query: %q", got) + } + + want := `SELECT id FROM users WHERE email = $1 AND is_admin = $2` + if got := db.DialectPostgres.Rebind(query); got != want { + t.Errorf("DialectPostgres.Rebind = %q; want %q", got, want) + } +} + +func TestDialectSQL(t *testing.T) { + const ( + sqlite = `UPDATE server_settings SET updated_at = datetime('now') WHERE id = 1` + postgres = `UPDATE server_settings SET updated_at = to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') WHERE id = 1` + ) + + if got := db.DialectSQLite.SQL(sqlite, postgres); got != sqlite { + t.Errorf("DialectSQLite.SQL = %q; want the sqlite statement", got) + } + if got := db.DialectPostgres.SQL(sqlite, postgres); got != postgres { + t.Errorf("DialectPostgres.SQL = %q; want the postgres statement", got) + } +} + +func TestDialectString(t *testing.T) { + if got := db.DialectSQLite.String(); got != "sqlite" { + t.Errorf("DialectSQLite.String() = %q; want %q", got, "sqlite") + } + if got := db.DialectPostgres.String(); got != "postgres" { + t.Errorf("DialectPostgres.String() = %q; want %q", got, "postgres") + } +} diff --git a/internal/dbtest/dbtest.go b/internal/dbtest/dbtest.go new file mode 100644 index 0000000..b0cbba4 --- /dev/null +++ b/internal/dbtest/dbtest.go @@ -0,0 +1,189 @@ +// Package dbtest opens a migrated database for a test, on whichever engine the +// environment selects. +// +// Unset CALNODE_TEST_POSTGRES_DSN — the default, and what upstream CI and every +// other contributor sees — means in-memory SQLite, exactly as before. Set it, and +// the same tests run against that PostgreSQL server, each in a schema of its own +// that is created before the migrations and dropped afterwards. Nothing here changes +// what a test asserts; it changes which engine has to satisfy it. +package dbtest + +import ( + "context" + "crypto/rand" + "encoding/hex" + "net/url" + "os" + "testing" + "time" + + "github.com/calnode/calnode/internal/db" +) + +// DSNEnv names the environment variable that switches the suite onto PostgreSQL. +const DSNEnv = "CALNODE_TEST_POSTGRES_DSN" + +// PostgresDSN returns the configured PostgreSQL DSN, or "" when the suite should +// run on SQLite. +func PostgresDSN() string { return os.Getenv(DSNEnv) } + +// Open returns a migrated handle for t, on Postgres when DSNEnv is set and on +// in-memory SQLite otherwise. The handle is closed when t finishes. +func Open(t *testing.T) *db.DB { + t.Helper() + if dsn := PostgresDSN(); dsn != "" { + return openPostgres(t, dsn) + } + return openSQLite(t) +} + +// RequirePostgres returns a migrated Postgres handle, skipping t when DSNEnv is +// unset. For the tests that are only meaningful on Postgres — a race the SQLite +// pool makes impossible, say. +func RequirePostgres(t *testing.T) *db.DB { + t.Helper() + dsn := PostgresDSN() + if dsn == "" { + t.Skipf("%s is not set; nothing to run against PostgreSQL", DSNEnv) + } + return openPostgres(t, dsn) +} + +func openSQLite(t *testing.T) *db.DB { + t.Helper() + h, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("dbtest: open sqlite: %v", err) + } + t.Cleanup(func() { h.Close() }) + if err := h.Migrate(); err != nil { + t.Fatalf("dbtest: migrate sqlite: %v", err) + } + return h +} + +// openPostgres gives t its own schema on the shared server. +// +// A schema rather than a database: CREATE DATABASE cannot run inside a +// transaction, takes seconds on a busy server, and needs rights a CI service +// container may not grant the test role. A schema is one statement, isolates +// tables and goose's own bookkeeping equally well, and disappears whole with +// DROP ... CASCADE. Everything reaches it through search_path on the connection, +// so no query in the tree needs to name it. +func openPostgres(t *testing.T, dsn string) *db.DB { + t.Helper() + + schema := "calnode_test_" + randomSuffix(t) + + // A second handle on the default search_path, kept open only to create and drop + // the schema: the drop cannot run through a connection whose search_path points + // at the schema being dropped. + admin, err := db.OpenDB(dsn) + if err != nil { + t.Fatalf("dbtest: open postgres (admin): %v", err) + } + if _, err := admin.Exec(`CREATE SCHEMA ` + quoteIdent(schema)); err != nil { + admin.Close() + t.Fatalf("dbtest: create schema %s: %v", schema, err) + } + // Registered first, so it runs last: the schema is dropped after the handle + // below has been closed. + t.Cleanup(func() { + defer admin.Close() + if err := dropSchema(admin, schema); err != nil { + t.Errorf("dbtest: drop schema %s: %v", schema, err) + } + }) + + h, err := db.OpenDB(withSearchPath(t, dsn, schema)) + if err != nil { + t.Fatalf("dbtest: open postgres: %v", err) + } + t.Cleanup(func() { h.Close() }) + + if err := h.Migrate(); err != nil { + t.Fatalf("dbtest: migrate postgres: %v", err) + } + return h +} + +// dropSchema drops the test's schema, retrying while work the test started is still +// finishing. +// +// Calnode's handlers do several things fire-and-forget: a booking spawns goroutines +// to notify hosts, enqueue the webhook and enqueue reminders. Those outlive the test +// BODY, and closing the pool does not stop them — database/sql closes idle +// connections and lets in-flight statements run to completion. DROP SCHEMA CASCADE +// needs an exclusive lock on every object in the schema, so it meets those +// statements and PostgreSQL reports "deadlock detected" (SQLSTATE 40P01). It showed +// up only under `go test ./...`, where packages run concurrently and everything is +// slower — two handler tests out of several hundred, in a different pair each run. +// +// lock_timeout is what makes retrying viable: without it the DROP either waits +// indefinitely or is chosen as a deadlock victim. With it the attempt fails fast and +// cheaply, and the background work it was contending with has finished by the next +// one. The budget is bounded so a schema that genuinely cannot be dropped is +// reported rather than waited on forever — a leaked schema accumulates on a shared +// server, so it is worth failing the test over. +// +// The statements run on ONE pinned connection because lock_timeout is per-session +// and the pool would otherwise be free to apply the SET to a different connection +// than the DROP. Neither statement has placeholders, so nothing needs rebinding. +func dropSchema(admin *db.DB, schema string) error { + ctx := context.Background() + conn, err := admin.Conn(ctx) + if err != nil { + return err + } + defer conn.Close() //nolint:errcheck // returning the drop's error is more useful + + if _, err := conn.ExecContext(ctx, `SET lock_timeout = '250ms'`); err != nil { + return err + } + + const attempts = 20 // ~5s of wall clock, against background work that takes ms + var lastErr error + for i := 0; i < attempts; i++ { + if _, lastErr = conn.ExecContext(ctx, `DROP SCHEMA `+quoteIdent(schema)+` CASCADE`); lastErr == nil { + return nil + } + time.Sleep(250 * time.Millisecond) + } + return lastErr +} + +// withSearchPath returns dsn with search_path pointed at schema. pgx passes +// unrecognised query parameters through as PostgreSQL runtime parameters, so this +// is the whole of the isolation: goose writes goose_db_version there, the +// migrations create their tables there, and every unqualified name in the tree +// resolves there. +func withSearchPath(t *testing.T, dsn, schema string) string { + t.Helper() + u, err := url.Parse(dsn) + if err != nil { + t.Fatalf("dbtest: parse %s: %v", DSNEnv, err) + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + return u.String() +} + +// randomSuffix keeps concurrent runs — two packages under `go test ./...`, or two +// CI jobs against one server — out of each other's schemas. +func randomSuffix(t *testing.T) string { + t.Helper() + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + t.Fatalf("dbtest: random schema name: %v", err) + } + return hex.EncodeToString(b) +} + +// quoteIdent quotes a generated schema name. The names are hex from the line +// above, so this is belt and braces rather than a defence — but a schema name +// interpolated into DDL unquoted is a bad habit to leave lying around for the next +// person who passes something else in. +func quoteIdent(name string) string { + return `"` + name + `"` +} diff --git a/internal/dbtest/dbtest_test.go b/internal/dbtest/dbtest_test.go new file mode 100644 index 0000000..25f188c --- /dev/null +++ b/internal/dbtest/dbtest_test.go @@ -0,0 +1,76 @@ +package dbtest + +import ( + "testing" + + "github.com/calnode/calnode/internal/db" +) + +// TestSearchPathIsolation checks the mechanism openPostgres relies on, without +// going through it: that a search_path in the DSN really does land unqualified +// objects in the per-test schema, and that they are invisible from the default +// path. If pgx ever stopped forwarding the parameter, openPostgres would silently +// migrate into the public schema and two packages would fight over one set of +// tables — which reads as flaky tests, not as a broken harness. +func TestSearchPathIsolation(t *testing.T) { + dsn := PostgresDSN() + if dsn == "" { + t.Skipf("%s is not set", DSNEnv) + } + + schema := "calnode_test_" + randomSuffix(t) + + admin, err := db.OpenDB(dsn) + if err != nil { + t.Fatalf("open admin: %v", err) + } + defer admin.Close() + if _, err := admin.Exec(`CREATE SCHEMA ` + quoteIdent(schema)); err != nil { + t.Fatalf("create schema: %v", err) + } + defer func() { + if _, err := admin.Exec(`DROP SCHEMA ` + quoteIdent(schema) + ` CASCADE`); err != nil { + t.Errorf("drop schema: %v", err) + } + }() + + scoped, err := db.OpenDB(withSearchPath(t, dsn, schema)) + if err != nil { + t.Fatalf("open scoped: %v", err) + } + defer scoped.Close() + + var got string + if err := scoped.QueryRow(`SELECT current_schema()`).Scan(&got); err != nil { + t.Fatalf("current_schema: %v", err) + } + if got != schema { + t.Fatalf("current_schema() = %q; want %q — search_path did not reach the server", got, schema) + } + + if _, err := scoped.Exec(`CREATE TABLE isolated (id text)`); err != nil { + t.Fatalf("create table: %v", err) + } + var visible int + if err := admin.QueryRow( + `SELECT COUNT(*) FROM pg_tables WHERE schemaname = current_schema() AND tablename = 'isolated'`). + Scan(&visible); err != nil { + t.Fatalf("count on default path: %v", err) + } + if visible != 0 { + t.Errorf("table 'isolated' is visible on the default search_path; the schema is not isolating anything") + } +} + +// TestOpen returns a handle that has been migrated, on whichever engine is +// configured. goose's bookkeeping table is the engine-independent proof. +func TestOpen(t *testing.T) { + h := Open(t) + var n int + if err := h.QueryRow(`SELECT COUNT(*) FROM goose_db_version`).Scan(&n); err != nil { + t.Fatalf("goose_db_version: %v", err) + } + if n == 0 { + t.Error("goose_db_version is empty; Open returned an unmigrated handle") + } +} diff --git a/internal/dbtime/dbtime.go b/internal/dbtime/dbtime.go new file mode 100644 index 0000000..a47dd76 --- /dev/null +++ b/internal/dbtime/dbtime.go @@ -0,0 +1,35 @@ +// Package dbtime formats "now" for Calnode's TEXT timestamp columns. +// +// Those columns used to be filled by the engine, with datetime('now') or +// strftime('%Y-%m-%dT%H:%M:%fZ','now'). Neither function exists in PostgreSQL, so +// the value is computed here and bound as an ordinary parameter instead: one +// statement that both engines accept, and a timestamp a test can control by +// comparing against a value it computed itself. +// +// The two layouts are the two shapes already in the schema, kept byte-identical to +// what SQLite wrote before. Normalising them to one would be tidier and wrong: the +// stored text is compared lexicographically (recordings scope consents to a time +// window that way) and handed to clients verbatim (GET /v1/notes/{id} returns +// updated_at), so a shape change would silently alter both. +package dbtime + +import "time" + +const ( + // DateTime is SQLite's datetime('now') output: "2006-01-02 15:04:05", UTC, + // second resolution, no zone suffix. + DateTime = "2006-01-02 15:04:05" + + // RFC3339Milli is SQLite's strftime('%Y-%m-%dT%H:%M:%fZ','now') output: + // RFC 3339 with exactly three fractional digits. %f is "SS.SSS", so the + // milliseconds are always present, including a trailing zero. + RFC3339Milli = "2006-01-02T15:04:05.000Z" +) + +// Now returns the current UTC time in the DateTime layout — the replacement for +// datetime('now'). +func Now() string { return time.Now().UTC().Format(DateTime) } + +// NowMilli returns the current UTC time in the RFC3339Milli layout — the +// replacement for strftime('%Y-%m-%dT%H:%M:%fZ','now'). +func NowMilli() string { return time.Now().UTC().Format(RFC3339Milli) } diff --git a/internal/dbtime/dbtime_test.go b/internal/dbtime/dbtime_test.go new file mode 100644 index 0000000..a0ec829 --- /dev/null +++ b/internal/dbtime/dbtime_test.go @@ -0,0 +1,51 @@ +package dbtime_test + +import ( + "regexp" + "testing" + + _ "modernc.org/sqlite" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" +) + +// The whole point of this package is that a Go-computed timestamp is +// indistinguishable from the one SQLite used to write. Assert that against SQLite +// itself rather than against a hand-read of its docs: if the shapes ever diverge, +// existing rows and new rows stop comparing lexicographically and the recordings +// consent window silently returns the wrong set. +func TestLayoutsMatchSQLite(t *testing.T) { + handle, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("db.OpenDB: %v", err) + } + t.Cleanup(func() { handle.Close() }) + + cases := []struct { + name string + expr string + got string + }{ + {"datetime('now')", `SELECT datetime('now')`, dbtime.Now()}, + {"strftime millis", `SELECT strftime('%Y-%m-%dT%H:%M:%fZ','now')`, dbtime.NowMilli()}, + } + + for _, c := range cases { + var want string + if err := handle.QueryRow(c.expr).Scan(&want); err != nil { + t.Fatalf("%s: %v", c.name, err) + } + // Compare shape, not value: the two clocks are read microseconds apart, so + // the digits legitimately differ. A digit-for-digit mask is what catches a + // layout mistake (missing "Z", " " where "T" belongs, 6 fractional digits). + if mask(want) != mask(c.got) { + t.Errorf("%s: sqlite wrote %q (shape %q), dbtime produced %q (shape %q)", + c.name, want, mask(want), c.got, mask(c.got)) + } + } +} + +var digits = regexp.MustCompile(`[0-9]`) + +func mask(s string) string { return digits.ReplaceAllString(s, "N") } diff --git a/internal/demo/demo.go b/internal/demo/demo.go index e37c6ef..2a50c7f 100644 --- a/internal/demo/demo.go +++ b/internal/demo/demo.go @@ -7,10 +7,11 @@ package demo import ( "context" - "database/sql" "fmt" + "strings" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) @@ -29,15 +30,18 @@ const ( // Monday-Friday availability, and a few upcoming sample bookings. Rows are // inserted directly via SQL, bypassing the HTTP layer — the same pattern // already used by this package's handler test fixtures. -func Seed(ctx context.Context, db *sql.DB) error { +func Seed(ctx context.Context, db *db.DB) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("demo seed: begin tx: %w", err) } defer tx.Rollback() //nolint:errcheck + // ON CONFLICT DO NOTHING rather than SQLite's INSERT OR IGNORE: both engines + // accept it. The conflict target is omitted, which covers any unique + // constraint, exactly as OR IGNORE did. if _, err := tx.ExecContext(ctx, - `INSERT OR IGNORE INTO server_settings (id) VALUES (1)`); err != nil { + `INSERT INTO server_settings (id) VALUES (1) ON CONFLICT DO NOTHING`); err != nil { return fmt.Errorf("demo seed: server_settings: %w", err) } @@ -181,25 +185,33 @@ func nextWeekdayAt(now time.Time, minDaysOut, hour int) time.Time { // dynamically rather than hardcoded, since this actively deletes data — // see docs/ARCHITECTURE.md's hardcoded-column-count incident for why a // stale hardcoded list is worth avoiding here specifically. -func Reset(ctx context.Context, db *sql.DB) (err error) { +func Reset(ctx context.Context, db *db.DB) (err error) { tables, err := listTables(ctx, db) if err != nil { return err } - // foreign_keys is connection-scoped and can't be toggled mid-transaction, - // so it's set here, before BeginTx — safe only because the pool is a - // single persistent connection (db.SetMaxOpenConns(1), internal/db/db.go). - // Needed because the delete order below is arbitrary relative to the - // schema's 46 migrations' worth of foreign-key relationships. - if _, ferr := db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); ferr != nil { - return fmt.Errorf("demo reset: disable foreign keys: %w", ferr) - } - defer func() { - if _, ferr := db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); ferr != nil && err == nil { - err = fmt.Errorf("demo reset: re-enable foreign keys: %w", ferr) + // The wipe order below is arbitrary relative to the schema's 46 migrations' + // worth of foreign keys, so referential integrity has to be stood down for it. + // The engines do that differently and neither way exists on the other: + // + // SQLite — PRAGMA foreign_keys is connection-scoped and cannot be toggled + // mid-transaction, so it is set before BeginTx. Safe only because the pool + // is one persistent connection (db.SetMaxOpenConns(1), internal/db/db.go). + // + // Postgres — there is no equivalent switch short of superuser rights, so a + // single TRUNCATE naming every table CASCADEs through the foreign keys + // instead. TRUNCATE is transactional there, so it stays inside the tx. + if isSQLite(db) { + if _, ferr := db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); ferr != nil { + return fmt.Errorf("demo reset: disable foreign keys: %w", ferr) } - }() + defer func() { + if _, ferr := db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); ferr != nil && err == nil { + err = fmt.Errorf("demo reset: re-enable foreign keys: %w", ferr) + } + }() + } tx, err := db.BeginTx(ctx, nil) if err != nil { @@ -207,9 +219,21 @@ func Reset(ctx context.Context, db *sql.DB) (err error) { } defer tx.Rollback() //nolint:errcheck - for _, t := range tables { - if _, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %q`, t)); err != nil { - return fmt.Errorf("demo reset: delete from %s: %w", t, err) + if isSQLite(db) { + for _, t := range tables { + if _, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %q`, t)); err != nil { + return fmt.Errorf("demo reset: delete from %s: %w", t, err) + } + } + } else if len(tables) > 0 { + quoted := make([]string, len(tables)) + for i, t := range tables { + quoted[i] = `"` + t + `"` + } + // #nosec G202 -- every name came from pg_tables in this schema, not from a request. + if _, err = tx.ExecContext(ctx, + `TRUNCATE TABLE `+strings.Join(quoted, ", ")+` CASCADE`); err != nil { + return fmt.Errorf("demo reset: truncate: %w", err) } } if err = tx.Commit(); err != nil { @@ -219,10 +243,20 @@ func Reset(ctx context.Context, db *sql.DB) (err error) { return Seed(ctx, db) } -func listTables(ctx context.Context, db *sql.DB) ([]string, error) { - rows, err := db.QueryContext(ctx, ` - SELECT name FROM sqlite_master - WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'goose_db_version'`) +// isSQLite is a free function rather than an inline comparison because Reset and +// listTables both name their handle "db", which shadows the package the Dialect +// constants live in. +func isSQLite(h *db.DB) bool { return h.Dialect() == db.DialectSQLite } + +func listTables(ctx context.Context, db *db.DB) ([]string, error) { + // sqlite_master has no Postgres counterpart. pg_tables scoped to + // current_schema() is the equivalent, and honouring the current schema is what + // lets a test run inside its own isolated one. + rows, err := db.QueryContext(ctx, db.Dialect().SQL( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'goose_db_version'`, + `SELECT tablename FROM pg_tables + WHERE schemaname = current_schema() AND tablename != 'goose_db_version'`)) if err != nil { return nil, fmt.Errorf("demo reset: list tables: %w", err) } diff --git a/internal/demo/demo_test.go b/internal/demo/demo_test.go index 183300c..cc7ecaf 100644 --- a/internal/demo/demo_test.go +++ b/internal/demo/demo_test.go @@ -2,27 +2,20 @@ package demo_test import ( "context" - "database/sql" "testing" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/demo" ) -func newMigratedDB(t *testing.T) *sql.DB { +func newMigratedDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - t.Cleanup(func() { database.Close() }) - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) return database } -func assertCount(t *testing.T, ctx context.Context, database *sql.DB, table string, want int) { +func assertCount(t *testing.T, ctx context.Context, database *db.DB, table string, want int) { t.Helper() var got int if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table).Scan(&got); err != nil { diff --git a/internal/gcal/gcal.go b/internal/gcal/gcal.go index 29b13e0..ed3e5eb 100644 --- a/internal/gcal/gcal.go +++ b/internal/gcal/gcal.go @@ -20,6 +20,7 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/connstore" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/oauthstore" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/uid" @@ -39,13 +40,13 @@ func (c *Client) InvitesGuests() bool { return true } type Client struct { config *oauth2.Config key [32]byte - db *sql.DB + db *db.DB logger *slog.Logger apiBase string // base URL for Calendar API; overridable in tests } // New creates a Client. encKeyHex is the 64-char hex AES-256 encryption key. -func New(db *sql.DB, clientID, clientSecret, redirectURL, encKeyHex string) (*Client, error) { +func New(db *db.DB, clientID, clientSecret, redirectURL, encKeyHex string) (*Client, error) { b, err := hex.DecodeString(encKeyHex) if err != nil || len(b) != 32 { return nil, fmt.Errorf("gcal: invalid encryption key") diff --git a/internal/gcal/gcal_test.go b/internal/gcal/gcal_test.go index e5c06d1..31780d6 100644 --- a/internal/gcal/gcal_test.go +++ b/internal/gcal/gcal_test.go @@ -2,7 +2,6 @@ package gcal import ( "context" - "database/sql" "strings" "testing" "time" @@ -10,20 +9,16 @@ import ( "golang.org/x/oauth2" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" + "github.com/calnode/calnode/internal/uid" ) // testKeyHex is a valid 64-char hex key (32 bytes) used across tests. const testKeyHex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("newTestDB: open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("newTestDB: migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return database } @@ -38,7 +33,7 @@ func newTestClient(t *testing.T) *Client { } // seedUser inserts a minimal user row so that calendar_connections FK is satisfied. -func seedUser(t *testing.T, db *sql.DB, userID string) { +func seedUser(t *testing.T, db *db.DB, userID string) { t.Helper() _, err := db.ExecContext(context.Background(), ` INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) @@ -314,16 +309,19 @@ func TestFreeBusyConnections_returnsConnectedCalendars(t *testing.T) { } // insertConnCal adds a per-account sub-calendar selection row for the google provider. -func insertConnCal(t *testing.T, db *sql.DB, userID, account, calID string, check bool) { +func insertConnCal(t *testing.T, db *db.DB, userID, account, calID string, check bool) { t.Helper() cc := 0 if check { cc = 1 } + // uid.New() rather than lower(hex(randomblob(16))): randomblob is a SQLite + // built-in with no PostgreSQL counterpart, and every other row in these tests + // already gets its id from uid. _, err := db.ExecContext(context.Background(), ` INSERT INTO connection_calendars (id, user_id, provider, account_email, calendar_id, name, check_conflicts, is_destination) - VALUES (lower(hex(randomblob(16))), ?, 'google', ?, ?, '', ?, 0)`, - userID, account, calID, cc) + VALUES (?, ?, 'google', ?, ?, '', ?, 0)`, + uid.New(), userID, account, calID, cc) if err != nil { t.Fatalf("insertConnCal(%q): %v", calID, err) } diff --git a/internal/handler/auth_google_test.go b/internal/handler/auth_google_test.go index 79b9b5a..13f2325 100644 --- a/internal/handler/auth_google_test.go +++ b/internal/handler/auth_google_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "log/slog" "net/http" "net/http/httptest" @@ -10,20 +9,15 @@ import ( "time" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/handler" ) // authTestSetup opens an in-memory DB, runs migrations, seeds one user, and // returns the handler, database handle, and the seeded user ID. -func authTestSetup(t *testing.T) (*handler.Handler, *sql.DB, string) { +func authTestSetup(t *testing.T) (*handler.Handler, *db.DB, string) { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) userID := "user-auth-test" @@ -35,7 +29,7 @@ func authTestSetup(t *testing.T) (*handler.Handler, *sql.DB, string) { } // seedSession inserts a session row and returns the session id (cookie value). -func seedSession(t *testing.T, db *sql.DB, userID string, ttl time.Duration) string { +func seedSession(t *testing.T, db *db.DB, userID string, ttl time.Duration) string { t.Helper() sessID := "test-session-" + userID expiresAt := time.Now().UTC().Add(ttl).Format(time.RFC3339) diff --git a/internal/handler/availability.go b/internal/handler/availability.go index 6577260..3eb766e 100644 --- a/internal/handler/availability.go +++ b/internal/handler/availability.go @@ -4,8 +4,8 @@ import ( "database/sql" "encoding/json" "net/http" - "strings" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) @@ -47,7 +47,7 @@ func (h *Handler) CreateAvailabilityRule(w http.ResponseWriter, r *http.Request) VALUES (?, ?, ?, ?, ?, ?)`, id, user.ID, req.EventTypeID, req.DayOfWeek, req.StartTime, req.EndTime) if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "a rule for this day and time already exists") return } @@ -177,7 +177,7 @@ func (h *Handler) UpdateAvailabilityRule(w http.ResponseWriter, r *http.Request) `UPDATE availability_rules SET day_of_week=?, start_time=?, end_time=? WHERE id=? AND user_id=?`, current.DayOfWeek, current.StartTime, current.EndTime, id, user.ID) if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "a rule for this day and time already exists") return } diff --git a/internal/handler/booking_filter_test.go b/internal/handler/booking_filter_test.go index 249adbb..40d1c99 100644 --- a/internal/handler/booking_filter_test.go +++ b/internal/handler/booking_filter_test.go @@ -1,13 +1,13 @@ package handler_test import ( - "database/sql" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/handler" ) @@ -53,7 +53,7 @@ func listIDs(r listResp) []string { } // seedBooking inserts one booking directly. start/end are RFC3339 UTC. -func seedBooking(t *testing.T, db *sql.DB, id, etID, hostID, start, end, status string) { +func seedBooking(t *testing.T, db *db.DB, id, etID, hostID, start, end, status string) { t.Helper() if _, err := db.Exec( `INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at, status) diff --git a/internal/handler/booking_handler.go b/internal/handler/booking_handler.go index 7f0c5d9..c4fce80 100644 --- a/internal/handler/booking_handler.go +++ b/internal/handler/booking_handler.go @@ -15,8 +15,10 @@ import ( "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/i18n" "github.com/calnode/calnode/internal/mailer" + "github.com/calnode/calnode/internal/metrics" "github.com/calnode/calnode/internal/slots" "github.com/calnode/calnode/internal/uid" "github.com/calnode/calnode/internal/webhook" @@ -734,7 +736,7 @@ func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) { if err := h.db.QueryRowContext(r.Context(), ` SELECT COUNT(*) FROM bookings b JOIN booking_attendees a ON a.booking_id = b.id AND a.is_organizer = 1 - WHERE a.email = ? COLLATE NOCASE AND b.created_at > ?`, + WHERE LOWER(a.email) = LOWER(?) AND b.created_at > ?`, req.Email, windowStart).Scan(&recent); err != nil { h.logger.ErrorContext(r.Context(), "create booking: per-email throttle", "error", err) } else if recent >= maxBookingsPerEmailPerHour { @@ -1165,6 +1167,10 @@ func (h *Handler) dispatchBookingConfirmation(b *booking.Booking, in bookingConf h.logger.Error("booking confirmation email (attendee)", "error", err, "booking_id", b.ID) } } + // Counted where the lifecycle event is dispatched, not where the webhook is enqueued: + // an instance with no webhooks configured still has bookings worth counting. This runs + // on every create path (REST + MCP) because dispatchBookingConfirmation is shared. + metrics.BookingEvent(metrics.BookingCreated) if h.webhookSvc != nil { if err := h.webhookSvc.Enqueue(ctx, "booking.created", webhook.BookingPayload{ ID: b.ID, @@ -1592,6 +1598,7 @@ func (h *Handler) cancelSideEffects(b booking.Booking) { _ = h.db.QueryRowContext(ctx, `SELECT payment_status, amount_paid_cents, amount_paid_currency FROM bookings WHERE id = ?`, b.ID). Scan(&payStatus, &payAmt, &payCur) + metrics.BookingEvent(metrics.BookingCancelled) if h.webhookSvc != nil { if err := h.webhookSvc.Enqueue(ctx, "booking.cancelled", webhook.BookingPayload{ ID: b.ID, @@ -1843,10 +1850,9 @@ func (h *Handler) loadHostPrefs(ctx context.Context, hostID string) (hostPrefs, return p, nil } -// isForeignKeyViolation reports whether err is a SQLite FOREIGN KEY constraint failure. -func isForeignKeyViolation(err error) bool { - return strings.Contains(err.Error(), "FOREIGN KEY constraint failed") -} +// isForeignKeyViolation reports whether err is a foreign-key violation, on either +// engine. A thin wrapper so the two call sites keep reading as a local predicate. +func isForeignKeyViolation(err error) bool { return db.IsForeignKeyViolation(err) } // enqueueReminder inserts a reminder.send job scheduled hoursBefore hours before startAt. // If the computed run_at has already passed, the job fires on the next poll cycle. @@ -1862,9 +1868,13 @@ func (h *Handler) enqueueReminder(ctx context.Context, bookingID string, startAt return fmt.Errorf("enqueue reminder: marshal payload: %w", err) } + // ON CONFLICT DO NOTHING is the portable spelling of SQLite's INSERT OR + // IGNORE, and drops the duplicate that jobs' UNIQUE(type, payload) rejects + // when the same reminder is enqueued twice. _, err = h.db.ExecContext(ctx, ` - INSERT OR IGNORE INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) - VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3)`, + INSERT INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) + VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3) + ON CONFLICT DO NOTHING`, uid.New(), string(payload), runAt.Format(time.RFC3339)) return err } @@ -1934,11 +1944,19 @@ func (h *Handler) replaceReminderJobs(ctx context.Context, bookingID, etID strin } defer tx.Rollback() //nolint:errcheck - if _, err := tx.ExecContext(ctx, ` - DELETE FROM jobs - WHERE type = 'reminder.send' - AND json_extract(payload, '$.booking_id') = ? - AND status != 'running'`, bookingID); err != nil { + // jobs.payload is TEXT holding JSON, and extracting a field out of it is the one + // operation here with no spelling both engines accept: json_extract is SQLite's + // JSON1 function, ->> needs an explicit cast because the column is TEXT rather + // than json. Hence a dialect pair rather than one statement. + if _, err := tx.ExecContext(ctx, tx.Dialect().SQL( + `DELETE FROM jobs + WHERE type = 'reminder.send' + AND json_extract(payload, '$.booking_id') = ? + AND status != 'running'`, + `DELETE FROM jobs + WHERE type = 'reminder.send' + AND payload::json ->> 'booking_id' = ? + AND status != 'running'`), bookingID); err != nil { return fmt.Errorf("replace reminder jobs: delete: %w", err) } @@ -1953,8 +1971,9 @@ func (h *Handler) replaceReminderJobs(ctx context.Context, bookingID, etID strin return fmt.Errorf("replace reminder jobs: marshal payload: %w", err) } if _, err := tx.ExecContext(ctx, ` - INSERT OR IGNORE INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) - VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3)`, + INSERT INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) + VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3) + ON CONFLICT DO NOTHING`, uid.New(), string(payload), runAt.Format(time.RFC3339)); err != nil { return fmt.Errorf("replace reminder jobs: insert: %w", err) } diff --git a/internal/handler/branding_settings.go b/internal/handler/branding_settings.go index 98e3f18..b59cfc3 100644 --- a/internal/handler/branding_settings.go +++ b/internal/handler/branding_settings.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/i18n" "github.com/calnode/calnode/internal/mailer" "github.com/disintegration/imaging" @@ -219,8 +220,8 @@ func (h *Handler) PatchBranding(w http.ResponseWriter, r *http.Request) { } if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET business_name = ?, logo_height = ?, logo_opacity = ?, - banner_opacity = ?, privacy_url = ?, terms_url = ?, fallback_locale = ?, updated_at = datetime('now') - WHERE id = 1`, req.BusinessName, req.LogoHeight, req.LogoOpacity, req.BannerOpacity, privacyURL, termsURL, req.FallbackLocale); err != nil { + banner_opacity = ?, privacy_url = ?, terms_url = ?, fallback_locale = ?, updated_at = ? + WHERE id = 1`, req.BusinessName, req.LogoHeight, req.LogoOpacity, req.BannerOpacity, privacyURL, termsURL, req.FallbackLocale, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "branding settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -316,7 +317,7 @@ func (h *Handler) UploadBrandingLogo(w http.ResponseWriter, r *http.Request) { logoURL := fmt.Sprintf("%s?v=%d", logoServePath, time.Now().Unix()) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET logo_url = ?, updated_at = datetime('now') WHERE id = 1`, logoURL); err != nil { + `UPDATE server_settings SET logo_url = ?, updated_at = ? WHERE id = 1`, logoURL, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "logo: update db", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -331,7 +332,7 @@ func (h *Handler) DeleteBrandingLogo(w http.ResponseWriter, r *http.Request) { } _ = os.Remove(filepath.Join(h.brandingDir(), "logo.png")) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET logo_url = '', updated_at = datetime('now') WHERE id = 1`); err != nil { + `UPDATE server_settings SET logo_url = '', updated_at = ? WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "logo: delete db", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -443,7 +444,7 @@ func (h *Handler) UploadBrandingBanner(w http.ResponseWriter, r *http.Request) { bannerURL := fmt.Sprintf("%s?v=%d", bannerServePath, time.Now().Unix()) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET banner_url = ?, updated_at = datetime('now') WHERE id = 1`, bannerURL); err != nil { + `UPDATE server_settings SET banner_url = ?, updated_at = ? WHERE id = 1`, bannerURL, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "banner: update db", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -458,7 +459,7 @@ func (h *Handler) DeleteBrandingBanner(w http.ResponseWriter, r *http.Request) { } _ = os.Remove(filepath.Join(h.brandingDir(), "banner.png")) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET banner_url = '', updated_at = datetime('now') WHERE id = 1`); err != nil { + `UPDATE server_settings SET banner_url = '', updated_at = ? WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "banner: delete db", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/calendar_test.go b/internal/handler/calendar_test.go index 5358ff2..fb112b2 100644 --- a/internal/handler/calendar_test.go +++ b/internal/handler/calendar_test.go @@ -9,7 +9,7 @@ import ( "testing" "github.com/calnode/calnode/internal/calendar" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/gcal" "github.com/calnode/calnode/internal/handler" ) @@ -21,13 +21,7 @@ const testGCalKeyHex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef // Returns (handler, gcalClient, plainAPIKey, userID). func newHandlerWithGCal(t *testing.T) (*handler.Handler, *gcal.Client, string, string) { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) h := handler.New(database, slog.Default()) diff --git a/internal/handler/checkbox_answer_test.go b/internal/handler/checkbox_answer_test.go index 335a66e..9e0714a 100644 --- a/internal/handler/checkbox_answer_test.go +++ b/internal/handler/checkbox_answer_test.go @@ -1,7 +1,6 @@ package handler_test import ( - "database/sql" "encoding/json" "fmt" "net/http" @@ -9,6 +8,7 @@ import ( "strings" "testing" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/handler" ) @@ -118,7 +118,7 @@ func TestCheckboxAnswer_normalisedToYesNo(t *testing.T) { } // ownerIDOf returns the sole workspace user's id. -func ownerIDOf(t *testing.T, database *sql.DB) string { +func ownerIDOf(t *testing.T, database *db.DB) string { t.Helper() var id string if err := database.QueryRow(`SELECT id FROM users ORDER BY created_at LIMIT 1`).Scan(&id); err != nil { diff --git a/internal/handler/email_settings.go b/internal/handler/email_settings.go index b8adc73..7dbf9b5 100644 --- a/internal/handler/email_settings.go +++ b/internal/handler/email_settings.go @@ -10,6 +10,8 @@ import ( "strconv" "time" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/mailer" "github.com/calnode/calnode/internal/secret" ) @@ -67,7 +69,7 @@ func BuildMailer(cfg SMTPConfig) (mailer.Mailer, EmailTransport) { // LoadEmailSettingsFromDB reads SMTP settings from server_settings and decrypts // the password. Returns nil (not an error) when smtp_host is empty — meaning // the settings have not been configured yet. -func LoadEmailSettingsFromDB(db *sql.DB, encKey [32]byte) (*SMTPConfig, error) { +func LoadEmailSettingsFromDB(db *db.DB, encKey [32]byte) (*SMTPConfig, error) { var host, port, user, passEnc, from, fromName, resendEnc string var smtpTLS, startTLS int err := db.QueryRow(` @@ -163,7 +165,7 @@ func (s emailSecret) String() string { return "smtp_pass_enc" } -// execer is the subset of *sql.DB / *sql.Tx the settings writes need, so they can be run +// execer is the subset of *db.DB / *db.Tx the settings writes need, so they can be run // inside a transaction. Saving email settings touches up to three columns across separate // statements; without a transaction a failure partway leaves the instance holding, say, a // new SMTP host with the previous password. @@ -185,14 +187,14 @@ func (h *Handler) storeEmailSecret(ctx context.Context, db execer, which emailSe var q string switch which { case secretResendAPIKey: - q = `UPDATE server_settings SET resend_api_key_enc = ?, updated_at = datetime('now') WHERE id = 1` + q = `UPDATE server_settings SET resend_api_key_enc = ?, updated_at = ? WHERE id = 1` case secretSMTPPass: - q = `UPDATE server_settings SET smtp_pass_enc = ?, updated_at = datetime('now') WHERE id = 1` + q = `UPDATE server_settings SET smtp_pass_enc = ?, updated_at = ? WHERE id = 1` default: return fmt.Errorf("unknown email secret %d", int(which)) } - if _, err := db.ExecContext(ctx, q, enc); err != nil { + if _, err := db.ExecContext(ctx, q, enc, dbtime.Now()); err != nil { return fmt.Errorf("store %s: %w", which, err) } return nil @@ -265,11 +267,11 @@ func (h *Handler) PatchEmailSettings(w http.ResponseWriter, r *http.Request) { smtp_host = ?, smtp_port = ?, smtp_user = ?, smtp_tls = ?, smtp_starttls = ?, email_from = ?, email_from_name = ?, - updated_at = datetime('now') + updated_at = ? WHERE id = 1`, req.SMTPHost, req.SMTPPort, req.SMTPUser, boolToInt(req.SMTPTLS), boolToInt(req.SMTPStartTLS), - req.EmailFrom, req.EmailFromName); err != nil { + req.EmailFrom, req.EmailFromName, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "email settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/email_settings_test.go b/internal/handler/email_settings_test.go index c290685..3df55f1 100644 --- a/internal/handler/email_settings_test.go +++ b/internal/handler/email_settings_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/mailer" ) @@ -61,7 +62,7 @@ func TestGetEmailSettings_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-get-email-test-key" hash := sha256HexForTest(rawKey) db.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u4','other3@example.com','Other3','UTC',0)`) - db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k4','u4','test',?,datetime('now'))`, hash) + db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k4','u4','test',?,?)`, hash, dbtime.Now()) req := authReq(http.MethodGet, "/v1/settings/email", "", rawKey) rec := httptest.NewRecorder() @@ -216,7 +217,7 @@ func TestPatchEmailSettings_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-test-key-xyz" hash := sha256HexForTest(rawKey) db.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u2','other@example.com','Other','UTC',0)`) - db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k2','u2','test',?,datetime('now'))`, hash) + db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k2','u2','test',?,?)`, hash, dbtime.Now()) req := authReq(http.MethodPatch, "/v1/settings/email", `{"smtp_host":"evil.smtp.example.com"}`, rawKey) rec := httptest.NewRecorder() @@ -286,7 +287,7 @@ func TestTestEmailConnection_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-conn-test-key" hash := sha256HexForTest(rawKey) db.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u3','other2@example.com','Other2','UTC',0)`) - db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k3','u3','test',?,datetime('now'))`, hash) + db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k3','u3','test',?,?)`, hash, dbtime.Now()) req := authReq(http.MethodPost, "/v1/settings/email/test", "", rawKey) rec := httptest.NewRecorder() diff --git a/internal/handler/event_type.go b/internal/handler/event_type.go index 40a9e98..b97ae09 100644 --- a/internal/handler/event_type.go +++ b/internal/handler/event_type.go @@ -7,6 +7,8 @@ import ( "net/http" "strings" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/uid" ) @@ -160,8 +162,12 @@ const selectETCols = "SELECT " + etColumns + " FROM event_types" // listEventTypesQuery returns every event type the user owns OR is an assigned // host on, with an `owned` flag (the owner is also seeded into event_type_hosts, // so ownership is keyed on event_types.user_id, not host membership). -const listEventTypesQuery = "SELECT " + etColumns + `, (user_id = ?) AS owned, - (archived_at IS NOT NULL) AS archived +// +// The two flags are CASE expressions rather than bare comparisons because a bare +// comparison's type is engine-dependent: SQLite yields 0/1 and PostgreSQL yields a +// boolean, and the boolean does not scan into the int the 0/1 columns beside it use. +const listEventTypesQuery = "SELECT " + etColumns + `, CASE WHEN user_id = ? THEN 1 ELSE 0 END AS owned, + CASE WHEN archived_at IS NOT NULL THEN 1 ELSE 0 END AS archived FROM event_types WHERE user_id = ? OR id IN (SELECT event_type_id FROM event_type_hosts WHERE user_id = ?) @@ -170,10 +176,10 @@ ORDER BY created_at` // getEventTypeQuery fetches one event type by slug if the user owns it or hosts // it, with the `owned` flag and the owner's name/email (so a read-only host knows // who to contact for changes). -const getEventTypeQuery = "SELECT " + etColumns + `, (user_id = ?) AS owned, +const getEventTypeQuery = "SELECT " + etColumns + `, CASE WHEN user_id = ? THEN 1 ELSE 0 END AS owned, (SELECT name FROM users WHERE id = event_types.user_id) AS owner_name, (SELECT email FROM users WHERE id = event_types.user_id) AS owner_email, - (archived_at IS NOT NULL) AS archived + CASE WHEN archived_at IS NOT NULL THEN 1 ELSE 0 END AS archived FROM event_types WHERE slug = ? AND (user_id = ? OR id IN (SELECT event_type_id FROM event_type_hosts WHERE user_id = ?))` @@ -315,11 +321,11 @@ func (h *Handler) CreateEventType(w http.ResponseWriter, r *http.Request) { routingMode, bufBefore, bufAfter, minNotice, maxFuture, maxActive, showTaken, defaultMsgConfirmation, defaultMsgCancellation, defaultMsgReschedule, defaultMsgReminder) if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "slug already in use") return } - if strings.Contains(err.Error(), "CHECK constraint failed") { + if db.IsCheckViolation(err) { h.writeError(w, http.StatusBadRequest, "invalid location_type or routing_mode value") return } @@ -596,9 +602,10 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { } if req.Archived != nil { if *req.Archived { - // strftime literal (no bound value) — matches the DB's timestamp format; - // also force is_active off so the archived type stops taking bookings. - setClauses = append(setClauses, "archived_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')") + // Bound value in the DB's millisecond timestamp format, which is what + // strftime('%Y-%m-%dT%H:%M:%fZ','now') wrote here before; also force + // is_active off so the archived type stops taking bookings. + set("archived_at", dbtime.NowMilli()) set("is_active", 0) } else { setClauses = append(setClauses, "archived_at = NULL") @@ -701,7 +708,7 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { "UPDATE event_types SET "+strings.Join(setClauses, ", ")+" WHERE slug = ? AND user_id = ?", // #nosec G202 -- setClauses is built by set()/the literal col list above; every column name is a hardcoded string, every value is bound via args... args...) if err != nil { - if strings.Contains(err.Error(), "CHECK constraint failed") { + if db.IsCheckViolation(err) { h.writeError(w, http.StatusBadRequest, "invalid location_type or routing_mode value") return } @@ -780,7 +787,7 @@ func (h *Handler) DeleteEventType(w http.ResponseWriter, r *http.Request) { res, err := h.db.ExecContext(r.Context(), `DELETE FROM event_types WHERE slug = ? AND user_id = ?`, slug, user.ID) if err != nil { - if strings.Contains(err.Error(), "FOREIGN KEY constraint failed") { + if db.IsForeignKeyViolation(err) { h.writeError(w, http.StatusConflict, "this event type has bookings in its history (including cancelled ones) and can't be deleted — deactivate it instead") return } diff --git a/internal/handler/google_settings.go b/internal/handler/google_settings.go index 965aa5e..cd70949 100644 --- a/internal/handler/google_settings.go +++ b/internal/handler/google_settings.go @@ -8,6 +8,8 @@ import ( "net/http" "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/gcal" "github.com/calnode/calnode/internal/secret" ) @@ -21,7 +23,7 @@ type GoogleOAuthConfig struct { // LoadGoogleSettingsFromDB reads Google OAuth credentials from server_settings // and decrypts the client secret. Returns nil (not an error) when client_id is empty. -func LoadGoogleSettingsFromDB(db *sql.DB, encKey [32]byte) (*GoogleOAuthConfig, error) { +func LoadGoogleSettingsFromDB(db *db.DB, encKey [32]byte) (*GoogleOAuthConfig, error) { var clientID, secretEnc string err := db.QueryRow(` SELECT google_client_id, google_client_secret_enc @@ -97,8 +99,8 @@ func (h *Handler) PatchGoogleSettings(w http.ResponseWriter, r *http.Request) { if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET google_client_id = '', google_client_secret_enc = '', - updated_at = datetime('now') - WHERE id = 1`); err != nil { + updated_at = ? + WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "google settings: clear", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -121,8 +123,8 @@ func (h *Handler) PatchGoogleSettings(w http.ResponseWriter, r *http.Request) { if _, err = h.db.ExecContext(r.Context(), ` UPDATE server_settings SET google_client_id = ?, google_client_secret_enc = ?, - updated_at = datetime('now') - WHERE id = 1`, req.ClientID, enc); err != nil { + updated_at = ? + WHERE id = 1`, req.ClientID, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "google settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -131,8 +133,8 @@ func (h *Handler) PatchGoogleSettings(w http.ResponseWriter, r *http.Request) { if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET google_client_id = ?, - updated_at = datetime('now') - WHERE id = 1`, req.ClientID); err != nil { + updated_at = ? + WHERE id = 1`, req.ClientID, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "google settings: update (keep secret)", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/google_settings_test.go b/internal/handler/google_settings_test.go index 0b8554d..56b012f 100644 --- a/internal/handler/google_settings_test.go +++ b/internal/handler/google_settings_test.go @@ -1,19 +1,20 @@ package handler_test import ( - "database/sql" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/handler" ) // newGoogleHandler sets up a handler with a valid enc key and returns it // alongside the DB and API key for an admin user. -func newGoogleHandler(t *testing.T) (*handler.Handler, *sql.DB, string) { +func newGoogleHandler(t *testing.T) (*handler.Handler, *db.DB, string) { t.Helper() h, database, apiKey, _ := setupWorkspaceWithDB(t) // Use the same key as calendar tests so gcal.New works if needed. @@ -80,7 +81,7 @@ func TestGetGoogleSettings_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-google-test-key" hash := sha256HexForTest(rawKey) database.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u-ng','ng@example.com','NG','UTC',0)`) - database.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k-ng','u-ng','test',?,datetime('now'))`, hash) + database.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k-ng','u-ng','test',?,?)`, hash, dbtime.Now()) req := authReq(http.MethodGet, "/v1/settings/google", "", rawKey) rec := httptest.NewRecorder() @@ -245,7 +246,7 @@ func TestPatchGoogleSettings_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-patch-google-key" hash := sha256HexForTest(rawKey) database.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u-npg','npg@example.com','NPG','UTC',0)`) - database.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k-npg','u-npg','test',?,datetime('now'))`, hash) + database.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k-npg','u-npg','test',?,?)`, hash, dbtime.Now()) rec := patchGoogleSettings(t, h, `{"client_id":"evil","client_secret":"evil"}`, rawKey) if rec.Code != http.StatusForbidden { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 6adbb08..e775949 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -1,7 +1,6 @@ package handler import ( - "database/sql" "encoding/hex" "log/slog" "net/http" @@ -12,16 +11,19 @@ import ( "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/livekit" "github.com/calnode/calnode/internal/llm" "github.com/calnode/calnode/internal/mailer" "github.com/calnode/calnode/internal/stripe" + "github.com/calnode/calnode/internal/stt" "github.com/calnode/calnode/internal/webhook" "github.com/calnode/calnode/internal/zoom" ) type Handler struct { - db *sql.DB + db *db.DB logger *slog.Logger bookingSvc *booking.Service mailer mailer.Mailer @@ -38,6 +40,9 @@ type Handler struct { googleAuth *oauth2.Config microsoftAuth *oauth2.Config secureCookie bool + ssoSecret string // HMAC key for the signed session hand-off; empty ⇒ /v1/auth/sso is off + metricsToken string // bearer token for GET /metrics; empty ⇒ that endpoint 404s + sttBaseURLCfg string // STT_BASE_URL override; empty ⇒ stt.DefaultBaseURL (see sttBaseURL) llmMu sync.RWMutex llm *llm.Client // nil when the optional LLM layer is off/unconfigured zoomMu sync.RWMutex @@ -62,7 +67,8 @@ func (h *Handler) SetLiveKit(c *livekit.Client) { // is no longer tracked), and would otherwise block the idempotent guard on its room forever. if c != nil { if _, err := h.db.Exec( - `UPDATE recordings SET status = 'complete', updated_at = datetime('now') WHERE status = 'active'`); err != nil { + `UPDATE recordings SET status = 'complete', updated_at = ? WHERE status = 'active'`, + dbtime.Now()); err != nil { h.logger.Warn("livekit: sweep stale recordings", "error", err) } } @@ -121,7 +127,7 @@ func (h *Handler) getLLM() *llm.Client { return h.llm } -func New(db *sql.DB, logger *slog.Logger) *Handler { +func New(db *db.DB, logger *slog.Logger) *Handler { whs, _ := webhook.New(db, "") // ephemeral key when no encryption key configured return &Handler{ db: db, @@ -172,6 +178,22 @@ func (h *Handler) publicURL() string { return h.baseURL } +// SetSTTBaseURL sets the speech-to-text endpoint host used by the notetaker. Empty keeps +// the provider default. +func (h *Handler) SetSTTBaseURL(url string) { + h.sttBaseURLCfg = url +} + +// sttBaseURL returns the effective endpoint host. Resolved on read rather than at set +// time so a Handler built without SetSTTBaseURL (every test) still reports the real +// default rather than an empty string. +func (h *Handler) sttBaseURL() string { + if h.sttBaseURLCfg != "" { + return h.sttBaseURLCfg + } + return stt.DefaultBaseURL +} + // SetDataDir sets the directory used for file uploads (avatars, etc.). func (h *Handler) SetDataDir(dir string) { h.dataDir = dir diff --git a/internal/handler/health.go b/internal/handler/health.go index 2d9d007..a36eb03 100644 --- a/internal/handler/health.go +++ b/internal/handler/health.go @@ -32,7 +32,10 @@ func (h *Handler) Readyz(w http.ResponseWriter, r *http.Request) { // Gate readiness on migrations: report not-ready until the schema is at the // embedded target version, so a provisioner polling /readyz never routes // traffic to an instance still mid-migration (or one that failed to migrate). - ready, err := db.SchemaReady(r.Context(), h.db) + // + // SchemaReady takes the bare pool: its one statement carries no placeholders, + // so there is nothing for the wrapper to rebind. + ready, err := db.SchemaReady(r.Context(), h.db.DB) if err != nil || !ready { if err != nil { h.logger.ErrorContext(r.Context(), "readyz: migration check failed", "error", err) diff --git a/internal/handler/health_test.go b/internal/handler/health_test.go index 08ae220..596d08a 100644 --- a/internal/handler/health_test.go +++ b/internal/handler/health_test.go @@ -8,18 +8,13 @@ import ( "testing" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/handler" ) func newTestHandler(t *testing.T) *handler.Handler { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return handler.New(database, slog.Default()) } @@ -76,7 +71,7 @@ func TestReadyz_returns200_whenDBHealthy(t *testing.T) { func TestReadyz_returns503_whenNotMigrated(t *testing.T) { // Open a DB but do NOT migrate it — the goose bookkeeping table is absent, // so the schema-readiness gate must report not-ready. - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { t.Fatalf("db.Open: %v", err) } @@ -115,7 +110,7 @@ func TestVersion_returns200(t *testing.T) { } func TestReadyz_returns503_whenDBClosed(t *testing.T) { - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { t.Fatalf("db.Open: %v", err) } diff --git a/internal/handler/idempotency.go b/internal/handler/idempotency.go index 91717f9..8991666 100644 --- a/internal/handler/idempotency.go +++ b/internal/handler/idempotency.go @@ -5,8 +5,9 @@ import ( "crypto/sha256" "database/sql" "encoding/hex" - "strings" "time" + + "github.com/calnode/calnode/internal/db" ) // idempotencyRecord is a previously-seen Idempotency-Key's stored outcome. @@ -38,7 +39,7 @@ func (h *Handler) claimIdempotencyKey(ctx context.Context, key, reqHash string) if err == nil { return nil, false, nil } - if !strings.Contains(err.Error(), "UNIQUE constraint failed") { + if !db.IsUniqueViolation(err) { return nil, false, err } diff --git a/internal/handler/livekit_recording.go b/internal/handler/livekit_recording.go index 33eafb4..d8f6b37 100644 --- a/internal/handler/livekit_recording.go +++ b/internal/handler/livekit_recording.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/livekit" "github.com/calnode/calnode/internal/uid" "github.com/calnode/calnode/internal/webhook" @@ -117,10 +118,13 @@ func (h *Handler) RecordStart(w http.ResponseWriter, r *http.Request) { } h.logger.InfoContext(r.Context(), "livekit: egress started", "room", room, "egress_id", egressID, "filepath", filepath) bookingID := strings.TrimPrefix(room, "booking-") + // created_at keeps the datetime('now') shape: parseRecordingTime reads it back + // and consentWindow turns it into the millisecond form the consent rows use. + now := dbtime.Now() if _, err := h.db.ExecContext(r.Context(), ` INSERT INTO recordings (id, booking_id, room, egress_id, status, object_key, created_at, updated_at) - VALUES (?, ?, ?, ?, 'active', ?, datetime('now'), datetime('now'))`, - uid.New(), bookingID, room, egressID, filepath); err != nil { + VALUES (?, ?, ?, ?, 'active', ?, ?, ?)`, + uid.New(), bookingID, room, egressID, filepath, now, now); err != nil { h.logger.ErrorContext(r.Context(), "livekit: save recording", "error", err) } h.mergeRoomMeta(r.Context(), room, "recording", true) // drives the consent banner @@ -167,8 +171,8 @@ func (h *Handler) finalizeActiveRecording(ctx context.Context, room string) { h.logger.ErrorContext(ctx, "livekit: stop egress", "error", err, "egress", egressID) } if _, err := h.db.ExecContext(ctx, - `UPDATE recordings SET status = 'complete', updated_at = datetime('now') - WHERE room = ? AND status = 'active'`, room); err != nil { + `UPDATE recordings SET status = 'complete', updated_at = ? + WHERE room = ? AND status = 'active'`, dbtime.Now(), room); err != nil { h.logger.ErrorContext(ctx, "livekit: close recording row", "error", err, "room", room) } } @@ -211,8 +215,8 @@ func (h *Handler) RecordConsent(w http.ResponseWriter, r *http.Request) { VALUES (?, ?, ?, ?) ON CONFLICT(room, participant_identity) DO UPDATE SET name = excluded.name, decision = excluded.decision, - decided_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, - room, identity, name, decision); err != nil { + decided_at = ?`, + room, identity, name, decision, dbtime.NowMilli()); err != nil { h.logger.ErrorContext(r.Context(), "livekit: record consent", "error", err, "room", room) h.writeError(w, http.StatusInternalServerError, "could not record consent") return @@ -603,8 +607,8 @@ func (h *Handler) LiveKitWebhook(w http.ResponseWriter, r *http.Request) { } if _, err := h.db.ExecContext(r.Context(), ` UPDATE recordings SET status = ?, object_key = COALESCE(NULLIF(?,''), object_key), - duration_s = ?, updated_at = datetime('now') WHERE egress_id = ?`, - status, key, durSec, info.EgressID); err != nil { + duration_s = ?, updated_at = ? WHERE egress_id = ?`, + status, key, durSec, dbtime.Now(), info.EgressID); err != nil { h.logger.ErrorContext(r.Context(), "livekit: finalize recording", "error", err) } if info.RoomName != "" { diff --git a/internal/handler/livekit_settings.go b/internal/handler/livekit_settings.go index daf4965..9606ccf 100644 --- a/internal/handler/livekit_settings.go +++ b/internal/handler/livekit_settings.go @@ -8,6 +8,8 @@ import ( "encoding/json" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/livekit" "github.com/calnode/calnode/internal/secret" ) @@ -21,7 +23,7 @@ type LiveKitConfig struct { // LoadLiveKitSettingsFromDB reads the LiveKit server config from server_settings and decrypts // the API secret. Returns nil (not an error) when the URL or key is empty (= not configured). -func LoadLiveKitSettingsFromDB(db *sql.DB, encKey [32]byte) (*LiveKitConfig, error) { +func LoadLiveKitSettingsFromDB(db *db.DB, encKey [32]byte) (*LiveKitConfig, error) { var url, apiKey, secretEnc string err := db.QueryRow(` SELECT livekit_url, livekit_api_key, livekit_api_secret_enc @@ -90,7 +92,7 @@ func (h *Handler) PatchLiveKitSettings(w http.ResponseWriter, r *http.Request) { if req.URL == "" { if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET livekit_url = '', livekit_api_key = '', - livekit_api_secret_enc = '', updated_at = datetime('now') WHERE id = 1`); err != nil { + livekit_api_secret_enc = '', updated_at = ? WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "livekit settings: clear", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -118,15 +120,15 @@ func (h *Handler) PatchLiveKitSettings(w http.ResponseWriter, r *http.Request) { } if _, err = h.db.ExecContext(r.Context(), ` UPDATE server_settings SET livekit_url = ?, livekit_api_key = ?, - livekit_api_secret_enc = ?, updated_at = datetime('now') WHERE id = 1`, - req.URL, req.APIKey, enc); err != nil { + livekit_api_secret_enc = ?, updated_at = ? WHERE id = 1`, + req.URL, req.APIKey, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "livekit settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return } } else if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET livekit_url = ?, livekit_api_key = ?, - updated_at = datetime('now') WHERE id = 1`, req.URL, req.APIKey); err != nil { + updated_at = ? WHERE id = 1`, req.URL, req.APIKey, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "livekit settings: update (keep secret)", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/llm_settings.go b/internal/handler/llm_settings.go index c5019f9..97bf4e8 100644 --- a/internal/handler/llm_settings.go +++ b/internal/handler/llm_settings.go @@ -9,6 +9,8 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/llm" "github.com/calnode/calnode/internal/secret" ) @@ -34,7 +36,7 @@ type LLMConfig struct { // LoadLLMSettingsFromDB reads the optional LLM settings from server_settings and decrypts // the api key. Returns nil (not an error) when the endpoint is empty (unconfigured). -func LoadLLMSettingsFromDB(db *sql.DB, encKey [32]byte) (*LLMConfig, error) { +func LoadLLMSettingsFromDB(db *db.DB, encKey [32]byte) (*LLMConfig, error) { var endpoint, model, keyEnc string var enabled int err := db.QueryRow(` @@ -154,8 +156,11 @@ func (h *Handler) PatchLLMSettings(w http.ResponseWriter, r *http.Request) { args = append(args, v) } if len(set) > 0 { + // updated_at's placeholder trails every "col = ?" in set, so its value + // trails every value in args. + args = append(args, dbtime.Now()) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET `+strings.Join(set, ", ")+`, updated_at = datetime('now') WHERE id = 1`, args...); err != nil { // #nosec G202 -- set is built above from hardcoded "col = ?" literals only; every value is bound via args... + `UPDATE server_settings SET `+strings.Join(set, ", ")+`, updated_at = ? WHERE id = 1`, args...); err != nil { // #nosec G202 -- set is built above from hardcoded "col = ?" literals only; every value is bound via args... h.llmDBError(w, r, err) return } diff --git a/internal/handler/manage_handler.go b/internal/handler/manage_handler.go index 401d1ff..39c3d28 100644 --- a/internal/handler/manage_handler.go +++ b/internal/handler/manage_handler.go @@ -13,6 +13,7 @@ import ( "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/i18n" "github.com/calnode/calnode/internal/mailer" + "github.com/calnode/calnode/internal/metrics" "github.com/calnode/calnode/internal/webhook" ) @@ -307,6 +308,11 @@ func (h *Handler) rescheduleSideEffects(bCopy booking.Booking, capturedEtID stri } } + // ⚠️ Only a real time change is counted. A host reassignment (reassign.go) also fires + // the booking.rescheduled webhook, because a subscriber does need to hear about it, but + // it does not move the meeting — counting it here would make "reschedules" answer a + // question nobody asked. + metrics.BookingEvent(metrics.BookingRescheduled) if h.webhookSvc != nil { if err := h.webhookSvc.Enqueue(ctx, "booking.rescheduled", webhook.BookingPayload{ ID: bCopy.ID, diff --git a/internal/handler/manage_test.go b/internal/handler/manage_test.go index 505ec4b..d71335a 100644 --- a/internal/handler/manage_test.go +++ b/internal/handler/manage_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "encoding/json" "fmt" "log/slog" @@ -13,27 +12,22 @@ import ( "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/handler" "github.com/calnode/calnode/internal/uid" ) // newTestHandlerDB creates a test handler and returns both the handler and the // underlying DB so tests can interact with the DB directly (e.g. to issue tokens). -func newTestHandlerDB(t *testing.T) (*handler.Handler, *sql.DB) { +func newTestHandlerDB(t *testing.T) (*handler.Handler, *db.DB) { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return handler.New(database, slog.Default()), database } // setupWorkspaceWithDB bootstraps a workspace and returns (handler, db, apiKey, userID). -func setupWorkspaceWithDB(t *testing.T) (*handler.Handler, *sql.DB, string, string) { +func setupWorkspaceWithDB(t *testing.T) (*handler.Handler, *db.DB, string, string) { t.Helper() h, database := newTestHandlerDB(t) @@ -59,7 +53,7 @@ func setupWorkspaceWithDB(t *testing.T) (*handler.Handler, *sql.DB, string, stri // seedFullAvailabilityDB is seedFullAvailability's DB-direct sibling, for secondary // hosts (e.g. a round-robin rotation member) inserted straight into the DB rather than // through /v1/setup — they have no API key of their own to call the HTTP endpoint with. -func seedFullAvailabilityDB(t *testing.T, database *sql.DB, userID string) { +func seedFullAvailabilityDB(t *testing.T, database *db.DB, userID string) { t.Helper() for day := 0; day < 7; day++ { if _, err := database.Exec( @@ -95,7 +89,7 @@ func createBookingViaHTTP(t *testing.T, h *handler.Handler, slug, startAt string } // issueTestToken issues a manage token for bookingID via the booking service. -func issueTestToken(t *testing.T, database *sql.DB, bookingID string) string { +func issueTestToken(t *testing.T, database *db.DB, bookingID string) string { t.Helper() svc := booking.New(database) tok, err := svc.IssueManageToken(context.Background(), bookingID) diff --git a/internal/handler/mcp_scope_test.go b/internal/handler/mcp_scope_test.go index 5d1aa2b..568aed6 100644 --- a/internal/handler/mcp_scope_test.go +++ b/internal/handler/mcp_scope_test.go @@ -11,20 +11,13 @@ import ( "time" "github.com/calnode/calnode/internal/booking" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) // verifies MCP tools scope by role: a member sees/controls only the bookings they host, // while an admin/owner (and the unauthenticated stdio operator) see the whole workspace. func TestMCP_roleScoping(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db open: %v", err) - } - t.Cleanup(func() { database.Close() }) - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) h := New(database, slog.Default()) // Owner (first user → owner+admin). diff --git a/internal/handler/metrics.go b/internal/handler/metrics.go new file mode 100644 index 0000000..99600ab --- /dev/null +++ b/internal/handler/metrics.go @@ -0,0 +1,93 @@ +package handler + +import ( + "crypto/sha256" + "crypto/subtle" + "net/http" + "strings" + + "github.com/calnode/calnode/internal/metrics" +) + +// SetMetricsToken configures the bearer token that authorises GET /metrics. Empty leaves +// the endpoint off. Set once at boot from config, like SetSSOSecret. +func (h *Handler) SetMetricsToken(token string) { + h.metricsToken = token +} + +// Metrics handles GET /metrics — Prometheus text exposition of the counters in +// internal/metrics plus the job-queue depth read from the database. +// +// ⛔ Gated on `Authorization: Bearer `, and it answers **404** — byte-identical +// to the mux's own not-found — when the token is unset or wrong. Not 401: a 401 confirms the +// endpoint exists and invites a guess, and the numbers here are a business feed (bookings +// created per hour, request volume by surface) on an instance whose whole point is being +// publicly reachable. An operator who has not configured a token has not opted in to +// publishing any of that, so there is nothing to advertise. +// +// The response is not rate-limited: a scrape runs every few seconds by design, and a +// limiter tuned for humans would drop samples and produce gaps that look like downtime. +// The token is the control. +func (h *Handler) Metrics(w http.ResponseWriter, r *http.Request) { + if !h.metricsAuthorized(r) { + http.NotFound(w, r) + return + } + + // Job depth lives in the database because any instance can claim any job, so it is + // read per scrape rather than counted in this process. One grouped query; a failure is + // logged and reported as zero rather than failing the whole scrape, since the process + // counters are still worth having when the database is the thing that is unwell (and + // /readyz is the endpoint that answers "is the database reachable"). + var q metrics.Queue + rows, err := h.db.QueryContext(r.Context(), + `SELECT status, COUNT(*) FROM jobs WHERE status IN ('pending', 'failed') GROUP BY status`) + if err != nil { + h.logger.ErrorContext(r.Context(), "metrics: count jobs", "error", err) + } else { + for rows.Next() { + var status string + var n int64 + if err := rows.Scan(&status, &n); err != nil { + h.logger.ErrorContext(r.Context(), "metrics: scan job count", "error", err) + continue + } + switch status { + case "pending": + q.Pending = n + case "failed": + q.Failed = n + } + } + if err := rows.Err(); err != nil { + h.logger.ErrorContext(r.Context(), "metrics: job count rows", "error", err) + } + rows.Close() // #nosec G104 -- rows already fully consumed; nothing actionable on close error + } + + w.Header().Set("Content-Type", metrics.ContentType) + // Cache-Control matters here: a scrape must never be answered from an intermediary, + // or a dashboard shows a frozen instance as a healthy one. + w.Header().Set("Cache-Control", "no-store") + if err := metrics.Write(w, q); err != nil { + h.logger.ErrorContext(r.Context(), "metrics: write exposition", "error", err) + } +} + +// metricsAuthorized reports whether the request carries the configured bearer token. +// +// The comparison is over SHA-256 digests rather than the raw strings: subtle.ConstantTimeCompare +// returns early when the lengths differ, so comparing the values directly would leak the +// token's length. Hashing makes both sides 32 bytes whatever was sent. +func (h *Handler) metricsAuthorized(r *http.Request) bool { + if h.metricsToken == "" { + return false + } + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + return false + } + presented := sha256.Sum256([]byte(strings.TrimPrefix(auth, "Bearer "))) + expected := sha256.Sum256([]byte(h.metricsToken)) + return subtle.ConstantTimeCompare(presented[:], expected[:]) == 1 +} diff --git a/internal/handler/metrics_test.go b/internal/handler/metrics_test.go new file mode 100644 index 0000000..1030245 --- /dev/null +++ b/internal/handler/metrics_test.go @@ -0,0 +1,107 @@ +package handler_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/calnode/calnode/internal/handler" + "github.com/calnode/calnode/internal/metrics" +) + +const metricsToken = "metrics-token-for-tests" + +func doMetrics(h *handler.Handler, auth string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + if auth != "" { + req.Header.Set("Authorization", auth) + } + rec := httptest.NewRecorder() + h.Metrics(rec, req) + return rec +} + +// Unset METRICS_TOKEN ⇒ 404, byte-identical to the mux's own not-found. A 401 would +// confirm the endpoint exists on an instance whose operator never opted in to publishing +// its booking rate. +func TestMetrics_404WithoutAToken(t *testing.T) { + h, _ := newTestHandlerDB(t) + + rec := doMetrics(h, "Bearer "+metricsToken) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d; want 404 — %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "calnode_") { + t.Error("body leaked metrics despite the endpoint being off") + } +} + +func TestMetrics_rejectsWrongOrMissingBearer(t *testing.T) { + cases := map[string]string{ + "no header": "", + "empty bearer": "Bearer ", + "wrong token": "Bearer nope", + "right token, no scheme": metricsToken, + "basic auth": "Basic " + metricsToken, + // A prefix of the real token must not pass: the comparison is over digests, so + // length tells an attacker nothing either. + "token prefix": "Bearer " + metricsToken[:10], + } + for name, auth := range cases { + t.Run(name, func(t *testing.T) { + h, _ := newTestHandlerDB(t) + h.SetMetricsToken(metricsToken) + + rec := doMetrics(h, auth) + + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d; want 404 for %q", rec.Code, auth) + } + }) + } +} + +func TestMetrics_servesExpositionWithTheToken(t *testing.T) { + metrics.Reset() + h, database := newTestHandlerDB(t) + h.SetMetricsToken(metricsToken) + + // Two pending jobs and one failed one, so the gauges are read from the table rather + // than reported as zero. The payload differs per row: jobs carries UNIQUE(type, + // payload), which is what makes an enqueue idempotent. + for _, row := range []struct{ id, status string }{ + {"job-1", "pending"}, {"job-2", "pending"}, {"job-3", "failed"}, {"job-4", "done"}, + } { + if _, err := database.Exec( + `INSERT INTO jobs (id, type, payload, run_at, status) VALUES (?, 'webhook.deliver', ?, '2026-01-01T00:00:00Z', ?)`, + row.id, `{"webhook_delivery_id":"`+row.id+`"}`, row.status); err != nil { + t.Fatalf("seed job %s: %v", row.id, err) + } + } + + rec := doMetrics(h, "Bearer "+metricsToken) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != metrics.ContentType { + t.Errorf("Content-Type = %q; want %q", ct, metrics.ContentType) + } + // A cached scrape shows a frozen instance as a healthy one. + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { + t.Errorf("Cache-Control = %q; want no-store", cc) + } + + body := rec.Body.String() + for _, want := range []string{ + "calnode_jobs_pending 2", + "calnode_jobs_failed_total 1", + "# TYPE calnode_build_info gauge", + } { + if !strings.Contains(body, want+"\n") { + t.Errorf("body missing %q:\n%s", want, body) + } + } +} diff --git a/internal/handler/notetaker.go b/internal/handler/notetaker.go index 2ef1d90..dd3df25 100644 --- a/internal/handler/notetaker.go +++ b/internal/handler/notetaker.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/llm" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/stt" @@ -59,13 +60,18 @@ func (h *Handler) deepgramKey(ctx context.Context) string { // reminders/webhooks enqueue via their own paths.) func (h *Handler) enqueueJob(ctx context.Context, typ string, payload any) error { b, _ := json.Marshal(payload) + // run_at keeps the datetime('now') shape it has always had here. It is + // deliberately not the RFC 3339 the reminder path writes: the worker's + // "run_at <= ?" compares text, and the space-separated form sorts before any + // T-separated one, which is what makes these jobs due immediately. + now := dbtime.Now() _, err := h.db.ExecContext(ctx, ` INSERT INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) - VALUES (?, ?, ?, datetime('now'), 'pending', 0, 3) + VALUES (?, ?, ?, ?, 'pending', 0, 3) ON CONFLICT(type, payload) DO UPDATE SET - status = 'pending', run_at = datetime('now'), attempts = 0, + status = 'pending', run_at = ?, attempts = 0, last_error = NULL, locked_until = NULL`, - uid.New(), typ, string(b)) + uid.New(), typ, string(b), now, now) return err } @@ -123,7 +129,7 @@ func (h *Handler) JobNotetakerTranscribe(ctx context.Context, payload string) er return nil } url := presignS3Get(s3, objectKey, time.Hour, timeNow()) - res, err := stt.NewDeepgram(key).TranscribeURL(ctx, url) + res, err := stt.NewDeepgram(key, h.sttBaseURL()).TranscribeURL(ctx, url) if err != nil { return err // transient — retry } @@ -212,16 +218,16 @@ func (h *Handler) summarizeBooking(ctx context.Context, bookingID string) (strin _, _ = h.db.ExecContext(ctx, ` INSERT INTO notes (id, booking_id, content, status) VALUES (?, ?, '', 'empty') - ON CONFLICT(booking_id) DO UPDATE SET status = 'empty', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, - uid.New(), bookingID) + ON CONFLICT(booking_id) DO UPDATE SET status = 'empty', updated_at = ?`, + uid.New(), bookingID, dbtime.NowMilli()) return "", nil } if _, err := h.db.ExecContext(ctx, ` INSERT INTO notes (id, booking_id, content, status) VALUES (?, ?, ?, 'complete') ON CONFLICT(booking_id) DO UPDATE SET - content = excluded.content, status = 'complete', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, - uid.New(), bookingID, content); err != nil { + content = excluded.content, status = 'complete', updated_at = ?`, + uid.New(), bookingID, content, dbtime.NowMilli()); err != nil { return "", err } h.logger.InfoContext(ctx, "notetaker: notes generated", "booking_id", bookingID, "chars", len(content)) diff --git a/internal/handler/notetaker_http.go b/internal/handler/notetaker_http.go index f5619c7..2576fdd 100644 --- a/internal/handler/notetaker_http.go +++ b/internal/handler/notetaker_http.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/secret" ) @@ -29,6 +30,10 @@ func (h *Handler) GetNotetakerSettings(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, http.StatusOK, map[string]any{ "enabled": enabled != 0, "stt_api_key_set": keyEnc != "", + // Read-only, and env-only (STT_BASE_URL): it names where recording audio is sent, + // which an admin should be able to read without shelling into the container, and + // should not be able to repoint from a browser session. + "stt_base_url": h.sttBaseURL(), }) } @@ -57,7 +62,7 @@ func (h *Handler) PatchNotetakerSettings(w http.ResponseWriter, r *http.Request) v = 1 } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET notetaker_enabled = ?, updated_at = datetime('now') WHERE id = 1`, v); err != nil { + `UPDATE server_settings SET notetaker_enabled = ?, updated_at = ? WHERE id = 1`, v, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "notetaker settings: update enabled", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -72,7 +77,7 @@ func (h *Handler) PatchNotetakerSettings(w http.ResponseWriter, r *http.Request) return } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET stt_api_key_enc = ?, updated_at = datetime('now') WHERE id = 1`, enc); err != nil { + `UPDATE server_settings SET stt_api_key_enc = ?, updated_at = ? WHERE id = 1`, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "notetaker settings: update key", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/notetaker_settings_test.go b/internal/handler/notetaker_settings_test.go new file mode 100644 index 0000000..3dabc95 --- /dev/null +++ b/internal/handler/notetaker_settings_test.go @@ -0,0 +1,60 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/calnode/calnode/internal/handler" + "github.com/calnode/calnode/internal/stt" +) + +func notetakerSettings(t *testing.T, h *handler.Handler, apiKey string) map[string]any { + t.Helper() + rec := httptest.NewRecorder() + h.RequireAuth(h.GetNotetakerSettings)(rec, authReq(http.MethodGet, "/v1/settings/notetaker", "", apiKey)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + return out +} + +// An admin should be able to read which endpoint recording audio is sent to without +// shelling into the container to look at the environment. +func TestNotetakerSettings_reportsSTTBaseURL(t *testing.T) { + h, _, apiKey, _ := setupWorkspaceWithDB(t) + + if got := notetakerSettings(t, h, apiKey)["stt_base_url"]; got != stt.DefaultBaseURL { + t.Errorf("stt_base_url = %v; want the provider default %q", got, stt.DefaultBaseURL) + } + + h.SetSTTBaseURL("https://api.eu.deepgram.com") + if got := notetakerSettings(t, h, apiKey)["stt_base_url"]; got != "https://api.eu.deepgram.com" { + t.Errorf("stt_base_url = %v; want the configured host", got) + } +} + +// Read-only: PATCH has no field for it, so a value posted under that name is ignored +// rather than stored. The endpoint answers with GetNotetakerSettings, so the response +// still reports the env-configured host. +func TestNotetakerSettings_sttBaseURLIsNotWritable(t *testing.T) { + h, _, apiKey, _ := setupWorkspaceWithDB(t) + h.SetSTTBaseURL("https://api.eu.deepgram.com") + + rec := httptest.NewRecorder() + h.RequireAuth(h.PatchNotetakerSettings)(rec, + authReq(http.MethodPatch, "/v1/settings/notetaker", + `{"enabled":true,"stt_base_url":"https://attacker.example.test"}`, apiKey)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + + if got := notetakerSettings(t, h, apiKey)["stt_base_url"]; got != "https://api.eu.deepgram.com" { + t.Errorf("stt_base_url = %v; a PATCH must not be able to repoint it", got) + } +} diff --git a/internal/handler/override.go b/internal/handler/override.go index aaa2274..d34db3a 100644 --- a/internal/handler/override.go +++ b/internal/handler/override.go @@ -4,9 +4,9 @@ import ( "database/sql" "encoding/json" "net/http" - "strings" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) @@ -155,7 +155,7 @@ func (h *Handler) CreateAvailabilityOverride(w http.ResponseWriter, r *http.Requ INSERT INTO availability_overrides (id, user_id, date, is_available, reason, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?, ?)`, id, user.ID, req.Date, isAvailInt, req.Reason, req.StartTime, req.EndTime); err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "an override already exists for this date; delete it first") return } diff --git a/internal/handler/reschedule_test.go b/internal/handler/reschedule_test.go index 26a01c7..7390481 100644 --- a/internal/handler/reschedule_test.go +++ b/internal/handler/reschedule_test.go @@ -106,13 +106,22 @@ func TestRescheduleBooking_updatesReminderJob(t *testing.T) { // replaceReminderJobs deletes old jobs and inserts fresh ones keyed by booking_id. // Poll until a pending reminder job with the correct run_at appears. + // + // The JSON lookup is a dialect pair for the same reason the production statement + // is one. The Scan error is captured rather than discarded: swallowing it turned a + // "json_extract does not exist" into an empty run_at and a two-second poll that + // then reported itself as a time-parsing failure. var runAt string + var lastErr error + payloadMatch := database.Dialect().SQL( + `json_extract(payload, '$.booking_id') = ?`, + `payload::json ->> 'booking_id' = ?`) deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - database.QueryRowContext(ctx, ` + lastErr = database.QueryRowContext(ctx, ` SELECT run_at FROM jobs WHERE type = 'reminder.send' - AND json_extract(payload, '$.booking_id') = ? + AND `+payloadMatch+` AND status = 'pending'`, bookingID). Scan(&runAt) if runAt != "" && runAt != oldReminderAt.Format(time.RFC3339) { @@ -120,6 +129,9 @@ func TestRescheduleBooking_updatesReminderJob(t *testing.T) { } time.Sleep(10 * time.Millisecond) } + if runAt == "" { + t.Fatalf("no pending reminder job appeared within 2s; last query error: %v", lastErr) + } gotRunAt, err := time.Parse(time.RFC3339, runAt) if err != nil { diff --git a/internal/handler/session.go b/internal/handler/session.go index 5f54cff..8b65102 100644 --- a/internal/handler/session.go +++ b/internal/handler/session.go @@ -3,7 +3,11 @@ package handler import ( "context" "crypto/rand" + "database/sql" "encoding/hex" + "encoding/json" + "errors" + "io" "net/http" "time" ) @@ -32,3 +36,129 @@ func (h *Handler) createSession(ctx context.Context, w http.ResponseWriter, user }) return nil } + +// RevokeAllSessions handles POST /v1/auth/sessions/revoke-all. +// +// Body: `{"user_id": "..."}`, optional. +// +// - Omitted (or naming the caller): signs the caller out everywhere **except the +// session that made the request**. "Sign out my other devices" is the action people +// actually want; dropping the current session too would log the operator out of the +// page they clicked it on, which is what Logout is for. A caller authenticating with +// an API key has no current session, so for them every session goes. +// - Naming someone else: an offboarding tool. Admin-only, and mirroring roles.go's +// tiers — an admin may revoke a member, only the owner may revoke another admin, and +// nobody may revoke the owner's sessions but the owner (there is exactly one owner, +// so that case is the self branch). +// +// It also deletes the target's MCP OAuth access tokens. An MCP connector authenticates +// with a bearer token rather than the session cookie (§19), so revoking sessions alone +// would leave an agent connected with exactly the authority that was just taken away — +// the failure mode being cut off from is a laptop that walked out of the building with a +// signed-in browser AND a connected agent on it. +func (h *Handler) RevokeAllSessions(w http.ResponseWriter, r *http.Request) { + actor, ok := userFromContext(r.Context()) + if !ok { + h.writeError(w, http.StatusUnauthorized, "authentication required") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 1<<10) + var req struct { + UserID string `json:"user_id"` + } + // An empty body is the common case (revoke my own), so EOF is not an error here. + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + h.writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + targetID := req.UserID + self := targetID == "" || targetID == actor.ID + if self { + targetID = actor.ID + } else { + // The actor's capability class is checked before the target is looked up, so a + // member cannot use this endpoint's 404 to probe which user ids exist. + if !actor.IsAdmin { + h.writeError(w, http.StatusForbidden, "admin access required") + return + } + var targetIsAdmin, targetIsOwner int + err := h.db.QueryRowContext(r.Context(), + `SELECT is_admin, is_owner FROM users WHERE id = ?`, targetID). + Scan(&targetIsAdmin, &targetIsOwner) + if err == sql.ErrNoRows { + h.writeError(w, http.StatusNotFound, "user not found") + return + } + if err != nil { + h.logger.ErrorContext(r.Context(), "revoke sessions: load target", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + if targetIsOwner != 0 { + h.writeError(w, http.StatusForbidden, "the owner's sessions can only be revoked by the owner") + return + } + if targetIsAdmin != 0 && !actor.IsOwner { + h.writeError(w, http.StatusForbidden, "only the workspace owner can revoke another admin's sessions") + return + } + } + + // One transaction: a caller told "revoked" must not have kept an MCP token because + // the second statement failed after the first committed. + tx, err := h.db.BeginTx(r.Context(), nil) + if err != nil { + h.logger.ErrorContext(r.Context(), "revoke sessions: begin tx", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + defer tx.Rollback() //nolint:errcheck + + var sessionRes sql.Result + if self { + current := "" + if c, cerr := r.Cookie(sessionCookieName); cerr == nil { + current = c.Value + } + sessionRes, err = tx.ExecContext(r.Context(), + `DELETE FROM sessions WHERE user_id = ? AND id <> ?`, targetID, current) + } else { + sessionRes, err = tx.ExecContext(r.Context(), + `DELETE FROM sessions WHERE user_id = ?`, targetID) + } + if err != nil { + h.logger.ErrorContext(r.Context(), "revoke sessions: delete sessions", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + tokenRes, err := tx.ExecContext(r.Context(), + `DELETE FROM oauth_access_tokens WHERE user_id = ?`, targetID) + if err != nil { + h.logger.ErrorContext(r.Context(), "revoke sessions: delete oauth tokens", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + if err := tx.Commit(); err != nil { + h.logger.ErrorContext(r.Context(), "revoke sessions: commit", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + sessions, _ := sessionRes.RowsAffected() + tokens, _ := tokenRes.RowsAffected() + h.logger.InfoContext(r.Context(), "sessions revoked", + "actor_id", actor.ID, "user_id", targetID, "self", self, + "sessions", sessions, "oauth_tokens", tokens) + + h.writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": targetID, + "sessions_revoked": sessions, + "oauth_tokens_revoked": tokens, + }) +} diff --git a/internal/handler/session_test.go b/internal/handler/session_test.go new file mode 100644 index 0000000..b84fc2c --- /dev/null +++ b/internal/handler/session_test.go @@ -0,0 +1,266 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/handler" +) + +// seedSessionID inserts a live session row under a caller-chosen id (the cookie value), +// so one user can be given several and a test can name the one it presents. +func seedSessionID(t *testing.T, database *db.DB, id, userID string) string { + t.Helper() + if _, err := database.Exec(`INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)`, + id, userID, time.Now().UTC().Add(24*time.Hour).Format(time.RFC3339)); err != nil { + t.Fatalf("seed session %s: %v", id, err) + } + return id +} + +// seedMCPToken inserts an MCP OAuth access token for userID. +func seedMCPToken(t *testing.T, database *db.DB, id, userID string) { + t.Helper() + if _, err := database.Exec(` + INSERT INTO oauth_access_tokens (id, token_hash, client_id, user_id, expires_at, created_at) + VALUES (?, ?, 'client-1', ?, ?, ?)`, + id, "hash-"+id, userID, + time.Now().UTC().Add(time.Hour).Format(time.RFC3339), + time.Now().UTC().Format(time.RFC3339)); err != nil { + t.Fatalf("seed mcp token %s: %v", id, err) + } +} + +func countSessions(t *testing.T, database *db.DB, userID string) int { + t.Helper() + var n int + if err := database.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`, userID).Scan(&n); err != nil { + t.Fatalf("count sessions: %v", err) + } + return n +} + +func countMCPTokens(t *testing.T, database *db.DB, userID string) int { + t.Helper() + var n int + if err := database.QueryRow(`SELECT COUNT(*) FROM oauth_access_tokens WHERE user_id = ?`, userID).Scan(&n); err != nil { + t.Fatalf("count mcp tokens: %v", err) + } + return n +} + +// revokeAll drives the handler through RequireAuth. cookie is the session cookie value +// to present (empty for none); apiKey authenticates when no cookie is given. +func revokeAll(h *handler.Handler, body, apiKey, cookie string) *httptest.ResponseRecorder { + var r *http.Request + if body != "" { + r = httptest.NewRequest(http.MethodPost, "/v1/auth/sessions/revoke-all", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + } else { + r = httptest.NewRequest(http.MethodPost, "/v1/auth/sessions/revoke-all", nil) + } + if apiKey != "" { + r.Header.Set("X-API-Key", apiKey) + } + if cookie != "" { + r.AddCookie(&http.Cookie{Name: "calnode_session", Value: cookie}) + } + rec := httptest.NewRecorder() + h.RequireAuth(h.RevokeAllSessions)(rec, r) + return rec +} + +// Revoking your own keeps the session that asked. That is the difference between this +// endpoint and Logout, and the reason it is the default with no body. +func TestRevokeAllSessions_selfKeepsTheCallingSession(t *testing.T) { + h, database, _, ownerID := setupWorkspaceWithDB(t) + current := seedSessionID(t, database, "sess-current", ownerID) + seedSessionID(t, database, "sess-laptop", ownerID) + seedSessionID(t, database, "sess-phone", ownerID) + + rec := revokeAll(h, "", "", current) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + var resp struct { + SessionsRevoked int `json:"sessions_revoked"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.SessionsRevoked != 2 { + t.Errorf("sessions_revoked = %d; want 2", resp.SessionsRevoked) + } + if n := countSessions(t, database, ownerID); n != 1 { + t.Fatalf("sessions left = %d; want 1 (the calling one)", n) + } + var left string + if err := database.QueryRow(`SELECT id FROM sessions WHERE user_id = ?`, ownerID).Scan(&left); err != nil { + t.Fatalf("read surviving session: %v", err) + } + if left != current { + t.Errorf("surviving session = %q; want %q", left, current) + } +} + +// An API-key caller has no current session, so there is none to spare. +func TestRevokeAllSessions_apiKeyCallerRevokesEveryone(t *testing.T) { + h, database, ownerKey, ownerID := setupWorkspaceWithDB(t) + seedSessionID(t, database, "sess-a", ownerID) + seedSessionID(t, database, "sess-b", ownerID) + + rec := revokeAll(h, "", ownerKey, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + if n := countSessions(t, database, ownerID); n != 0 { + t.Errorf("sessions left = %d; want 0", n) + } +} + +// An MCP connector holds a bearer token, not a cookie. Revoking sessions and leaving it +// would hand back exactly the access that was just withdrawn. +func TestRevokeAllSessions_cutsMCPTokensToo(t *testing.T) { + h, database, ownerKey, ownerID := setupWorkspaceWithDB(t) + seedMCPToken(t, database, "tok-1", ownerID) + seedMCPToken(t, database, "tok-2", ownerID) + + rec := revokeAll(h, "", ownerKey, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + var resp struct { + OAuthTokensRevoked int `json:"oauth_tokens_revoked"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.OAuthTokensRevoked != 2 { + t.Errorf("oauth_tokens_revoked = %d; want 2", resp.OAuthTokensRevoked) + } + if n := countMCPTokens(t, database, ownerID); n != 0 { + t.Errorf("mcp tokens left = %d; want 0", n) + } +} + +// seedRoleUser inserts a user with the given flags plus an API key for them. +func seedRoleUser(t *testing.T, database *db.DB, id, email string, isAdmin, isOwner int, apiKey string) { + t.Helper() + if _, err := database.Exec( + `INSERT INTO users (id,email,name,iana_timezone,is_admin,is_owner) VALUES (?,?,?,'UTC',?,?)`, + id, email, id, isAdmin, isOwner); err != nil { + t.Fatalf("seed user %s: %v", id, err) + } + if apiKey != "" { + if _, err := database.Exec( + `INSERT INTO api_keys (id,user_id,name,key_hash,created_at) VALUES (?,?,'t',?,'2024-01-01')`, + "key-"+id, id, sha256HexForTest(apiKey)); err != nil { + t.Fatalf("seed api key for %s: %v", id, err) + } + } +} + +func TestRevokeAllSessions_memberCannotTargetAnotherUser(t *testing.T) { + h, database, _, _ := setupWorkspaceWithDB(t) + seedRoleUser(t, database, "member-1", "m1@example.com", 0, 0, "member-1-key") + seedRoleUser(t, database, "member-2", "m2@example.com", 0, 0, "") + seedSessionID(t, database, "sess-victim", "member-2") + + rec := revokeAll(h, `{"user_id":"member-2"}`, "member-1-key", "") + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d; want 403 — %s", rec.Code, rec.Body.String()) + } + if n := countSessions(t, database, "member-2"); n != 1 { + t.Errorf("victim sessions = %d; want 1 (untouched)", n) + } +} + +func TestRevokeAllSessions_adminRevokesAMember(t *testing.T) { + h, database, _, _ := setupWorkspaceWithDB(t) + seedRoleUser(t, database, "admin-1", "a1@example.com", 1, 0, "admin-1-key") + seedRoleUser(t, database, "member-1", "m1@example.com", 0, 0, "") + seedSessionID(t, database, "sess-m1a", "member-1") + seedSessionID(t, database, "sess-m1b", "member-1") + seedMCPToken(t, database, "tok-m1", "member-1") + + rec := revokeAll(h, `{"user_id":"member-1"}`, "admin-1-key", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + if n := countSessions(t, database, "member-1"); n != 0 { + t.Errorf("member sessions = %d; want 0", n) + } + if n := countMCPTokens(t, database, "member-1"); n != 0 { + t.Errorf("member mcp tokens = %d; want 0", n) + } +} + +func TestRevokeAllSessions_onlyTheOwnerRevokesAnAdmin(t *testing.T) { + h, database, ownerKey, _ := setupWorkspaceWithDB(t) + seedRoleUser(t, database, "admin-1", "a1@example.com", 1, 0, "admin-1-key") + seedRoleUser(t, database, "admin-2", "a2@example.com", 1, 0, "") + seedSessionID(t, database, "sess-a2", "admin-2") + + // Admin → admin is refused. + rec := revokeAll(h, `{"user_id":"admin-2"}`, "admin-1-key", "") + if rec.Code != http.StatusForbidden { + t.Fatalf("admin targeting admin: status = %d; want 403 — %s", rec.Code, rec.Body.String()) + } + if n := countSessions(t, database, "admin-2"); n != 1 { + t.Fatalf("admin-2 sessions = %d; want 1 (untouched)", n) + } + + // Owner → admin is allowed. + rec = revokeAll(h, `{"user_id":"admin-2"}`, ownerKey, "") + if rec.Code != http.StatusOK { + t.Fatalf("owner targeting admin: status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + if n := countSessions(t, database, "admin-2"); n != 0 { + t.Errorf("admin-2 sessions = %d; want 0", n) + } +} + +// Nobody signs the owner out but the owner, mirroring roles.go refusing to change the +// owner's role. +func TestRevokeAllSessions_ownerIsOffLimitsToAdmins(t *testing.T) { + h, database, _, ownerID := setupWorkspaceWithDB(t) + seedRoleUser(t, database, "admin-1", "a1@example.com", 1, 0, "admin-1-key") + seedSessionID(t, database, "sess-owner", ownerID) + + rec := revokeAll(h, `{"user_id":"`+ownerID+`"}`, "admin-1-key", "") + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d; want 403 — %s", rec.Code, rec.Body.String()) + } + if n := countSessions(t, database, ownerID); n != 1 { + t.Errorf("owner sessions = %d; want 1 (untouched)", n) + } +} + +func TestRevokeAllSessions_unknownUserIs404(t *testing.T) { + h, _, ownerKey, _ := setupWorkspaceWithDB(t) + + rec := revokeAll(h, `{"user_id":"nobody"}`, ownerKey, "") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d; want 404 — %s", rec.Code, rec.Body.String()) + } +} + +// Naming yourself is the self branch, not the admin branch: a member may do it. +func TestRevokeAllSessions_namingYourselfIsSelf(t *testing.T) { + h, database, _, _ := setupWorkspaceWithDB(t) + seedRoleUser(t, database, "member-1", "m1@example.com", 0, 0, "member-1-key") + seedSessionID(t, database, "sess-m1", "member-1") + + rec := revokeAll(h, `{"user_id":"member-1"}`, "member-1-key", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + if n := countSessions(t, database, "member-1"); n != 0 { + t.Errorf("sessions left = %d; want 0 (api-key caller has no current session)", n) + } +} diff --git a/internal/handler/slots_busy_test.go b/internal/handler/slots_busy_test.go index 9d4be97..62ba09a 100644 --- a/internal/handler/slots_busy_test.go +++ b/internal/handler/slots_busy_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) // TestHostAvailability_includesAdjacentUTCDayBooking is the regression guard for @@ -16,14 +16,7 @@ import ( // generation would offer a slot the host is already booked for (then 409 at // booking time). Everything is stored UTC — this guards the fetch *window*. func TestHostAvailability_includesAdjacentUTCDayBooking(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open db: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) h := New(database, slog.New(slog.DiscardHandler)) // Host in NZ; a booking at 18 Jun 10:00 NZST = 17 Jun 22:00Z (previous UTC day). @@ -60,14 +53,7 @@ func TestHostAvailability_includesAdjacentUTCDayBooking(t *testing.T) { // bookings.host_id) — otherwise their slots on other events stay open and they // get double-booked. func TestHostAvailability_includesNonPrimaryGroupSeat(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open db: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) h := New(database, slog.New(slog.DiscardHandler)) database.Exec(`INSERT INTO users (id,email,name,iana_timezone,is_admin,is_owner) VALUES ('u1','a@example.com','A','UTC',1,1)`) diff --git a/internal/handler/sso.go b/internal/handler/sso.go new file mode 100644 index 0000000..907dd61 --- /dev/null +++ b/internal/handler/sso.go @@ -0,0 +1,356 @@ +package handler + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/uid" +) + +const ( + // ssoMaxTokenLifetime caps exp - iat. A hand-off is a redirect the browser follows + // immediately, so it needs seconds, not minutes. + ssoMaxTokenLifetime = 60 * time.Second + + // ssoClockSkew is how far the two systems' clocks may disagree before a token is + // refused, applied to both ends of the window. + ssoClockSkew = 30 * time.Second + + // ssoDefaultNext is where a hand-off lands when no ?next= is given. + ssoDefaultNext = "/admin/" +) + +// SetSSOSecret configures the shared secret for the SSO hand-off endpoint. An empty +// secret leaves the endpoint off (it 404s). Set once at boot from config, like +// SetBaseURL and SetEncKey, so there is no lock here. +func (h *Handler) SetSSOSecret(secret string) { + h.ssoSecret = secret +} + +// ssoClaims is the token payload. Every field except wid is required. +type ssoClaims struct { + Iss string `json:"iss"` // issuing system, any non-empty string; logged, never authorised on + Aud string `json:"aud"` // must equal this instance's BASE_URL + Sub string `json:"sub"` // the person's email address + Name string `json:"name"` // display name, used when the user is created + Role string `json:"role"` // owner | admin | member + Iat int64 `json:"iat"` // issued at, seconds since the epoch + Exp int64 `json:"exp"` // expires at, seconds since the epoch + JTI string `json:"jti"` // unique per token; replay is refused + + // WID names a workspace. Parsed and deliberately ignored: an instance is a single + // workspace today, so there is nothing to select. A multi-workspace mode will use + // it to decide which workspace the hand-off lands in, and accepting it now means a + // caller written against that future does not have to be changed to work today. + WID string `json:"wid"` +} + +// SSOHandoff handles GET /v1/auth/sso?token=[&next=/path] — the signed session +// hand-off. An external identity system that has already authenticated a person hands +// them into a Calnode session, without a second login. +// +// The token is a compact HS256 JWT signed with a secret the two systems share +// (CALNODE_SSO_SHARED_SECRET); the endpoint is off unless that is set. It is verified +// here rather than by a JWT library for the same reason internal/livekit signs its own: +// one algorithm, one key, a fixed claim set, and no dependency to keep current. Only +// HS256 is accepted — "none" and every asymmetric alg are refused before the signature +// is looked at, which is the classic JWT downgrade. +// +// Two properties do the security work, and neither is optional: +// +// - The token is short-lived (exp at most 60s after iat) so a captured URL is not a +// standing credential. 30s of clock skew is allowed in both directions, because the +// two systems are separate hosts and NTP is not a guarantee. +// - The token is single-use: its jti is claimed in sso_nonces before the session is +// created, so a replay inside the validity window loses on the primary key. +// +// This is the ONE path that creates a user without an invite (see ssoResolveUser). +// Everywhere else an unknown email is refused; the shared secret is what makes this +// different — the caller is the operator's own identity system, not an arbitrary +// visitor with a Google account. +// +// Success is a 302 into the admin app (or ?next=, when that is a same-origin absolute +// path). Every failure is a 401 with a JSON body naming the claim that failed, so an +// operator wiring this up can tell a clock problem from a wrong audience — the body +// never carries the secret, the signature, or the token. +func (h *Handler) SSOHandoff(w http.ResponseWriter, r *http.Request) { + if h.ssoSecret == "" { + // Off unless CALNODE_SSO_SHARED_SECRET is set, and 404 rather than 501 so an + // instance that has not configured SSO is indistinguishable from one that does + // not implement it. Nothing is disclosed to a prober either way. + h.writeError(w, http.StatusNotFound, "not found") + return + } + + token := r.URL.Query().Get("token") + if token == "" { + h.writeError(w, http.StatusUnauthorized, "token is required") + return + } + + claims, err := verifySSOToken(token, h.ssoSecret) + if err != nil { + h.logger.WarnContext(r.Context(), "sso: token rejected", "reason", err.Error()) + h.writeError(w, http.StatusUnauthorized, err.Error()) + return + } + if err := claims.validate(h.baseURL, time.Now()); err != nil { + h.logger.WarnContext(r.Context(), "sso: claims rejected", "reason", err.Error(), "iss", claims.Iss) + h.writeError(w, http.StatusUnauthorized, err.Error()) + return + } + + // Validated before the nonce is claimed: a bad ?next= is the caller's own bug, and + // burning the token over it would make the retry fail for a second, unrelated reason. + next, err := ssoNextPath(r.URL.Query().Get("next")) + if err != nil { + h.writeError(w, http.StatusBadRequest, err.Error()) + return + } + + // Claim the jti before anything is created. On a replay this is the statement that + // fails, and it fails on the primary key rather than on a read-then-write the second + // request could interleave with. + if _, err := h.db.ExecContext(r.Context(), + `INSERT INTO sso_nonces (jti, expires_at) VALUES (?, ?)`, + claims.JTI, time.Unix(claims.Exp, 0).UTC().Format(time.RFC3339)); err != nil { + if db.IsUniqueViolation(err) { + h.logger.WarnContext(r.Context(), "sso: token replayed", "iss", claims.Iss) + h.writeError(w, http.StatusUnauthorized, "jti has already been used") + return + } + h.logger.ErrorContext(r.Context(), "sso: claim nonce", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + userID, created, err := h.ssoResolveUser(r.Context(), claims) + switch { + case errors.Is(err, errSSOArchived): + h.writeError(w, http.StatusUnauthorized, "account is archived") + return + case err != nil: + h.logger.ErrorContext(r.Context(), "sso: resolve user", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + if err := h.createSession(r.Context(), w, userID); err != nil { + h.logger.ErrorContext(r.Context(), "sso: create session", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + h.logger.InfoContext(r.Context(), "sso: session handed off", + "iss", claims.Iss, "user_id", userID, "user_created", created) + http.Redirect(w, r, next, http.StatusFound) +} + +// errSSOArchived reports that the token named a real but offboarded user. Archived +// means no login by every other path (see §6), and a shared secret does not change that. +var errSSOArchived = errors.New("sso: user is archived") + +// ssoResolveUser maps the token's sub to a user id, creating the user when the email is +// unknown. It reports whether the user was created. +// +// Role handling is deliberately asymmetric. On creation the claim's role is applied, so +// the identity system provisions people directly. On an existing user the role is left +// alone — a workspace's roles are the workspace's business, and letting a hand-off +// rewrite them on every sign-in would make the admin UI's role controls advisory. The +// single exception is bootstrapping: a claim asking for owner is honoured when the +// instance has no owner, because the one-owner invariant TransferOwnership maintains +// means there is nothing to displace. +func (h *Handler) ssoResolveUser(ctx context.Context, claims ssoClaims) (userID string, created bool, err error) { + email := strings.ToLower(strings.TrimSpace(claims.Sub)) + + var archivedAt sql.NullString + err = h.db.QueryRowContext(ctx, + `SELECT id, archived_at FROM users WHERE email = ?`, email).Scan(&userID, &archivedAt) + switch { + case err == sql.ErrNoRows: + // Unknown email: create. This is the one path that creates a user without an + // invite; it is reachable only by a caller holding the shared secret. + isAdmin, isOwner := 0, 0 + switch claims.Role { + case "owner": + isAdmin = 1 + if !h.ssoOwnerExists(ctx) { + isOwner = 1 + } + case "admin": + isAdmin = 1 + } + userID = uid.New() + if _, err := h.db.ExecContext(ctx, ` + INSERT INTO users (id, email, name, iana_timezone, is_admin, is_owner, email_login) + VALUES (?, ?, ?, 'UTC', ?, ?, 0)`, + userID, email, claims.Name, isAdmin, isOwner); err != nil { + return "", false, err + } + return userID, true, nil + + case err != nil: + return "", false, err + } + + if archivedAt.Valid { + return "", false, errSSOArchived + } + if claims.Role == "owner" && !h.ssoOwnerExists(ctx) { + if _, err := h.db.ExecContext(ctx, + `UPDATE users SET is_owner = 1, is_admin = 1 WHERE id = ?`, userID); err != nil { + return "", false, err + } + } + return userID, false, nil +} + +// ssoOwnerExists reports whether the instance already has an owner. A read error is +// reported as "yes" so a failed check never hands out ownership. +func (h *Handler) ssoOwnerExists(ctx context.Context) bool { + var n int + if err := h.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM users WHERE is_owner = 1 AND archived_at IS NULL`).Scan(&n); err != nil { + h.logger.ErrorContext(ctx, "sso: count owners", "error", err) + return true + } + return n > 0 +} + +// ssoNextPath validates the optional ?next= target, returning the default when it is +// absent. Only a same-origin absolute path is allowed; anything else is refused rather +// than sanitised, because a redirect built from a partially-cleaned value is how open +// redirects survive their own fix. +func ssoNextPath(next string) (string, error) { + if next == "" { + return ssoDefaultNext, nil + } + switch { + case !strings.HasPrefix(next, "/"): + return "", errors.New("next must be an absolute path") + case strings.HasPrefix(next, "//"): + // Protocol-relative: "//evil.example" is another origin, not a local path. + return "", errors.New("next must not start with //") + case strings.Contains(next, `\`): + // Some browsers normalise a backslash to a slash, so "/\evil.example" is a + // protocol-relative URL wearing a disguise. + return "", errors.New(`next must not contain a backslash`) + case strings.Contains(next, "://"): + return "", errors.New("next must not contain a scheme") + } + for _, c := range next { + if c < 0x20 || c == 0x7f { + // A CR or LF would split the Location header; the rest are never legitimate + // in a path either. + return "", errors.New("next must not contain control characters") + } + } + return next, nil +} + +// verifySSOToken parses a compact JWS and verifies its HS256 signature with secret, +// returning the claims. The error names what was wrong in terms the operator can act +// on, and never echoes any part of the token back — an error body is attacker-reachable. +func verifySSOToken(token, secret string) (ssoClaims, error) { + var claims ssoClaims + + parts := strings.Split(token, ".") + if len(parts) != 3 { + return claims, errors.New("token is not a three-part JWT") + } + + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return claims, errors.New("token header is not base64url") + } + var header struct { + Alg string `json:"alg"` + Typ string `json:"typ"` + } + if err := json.Unmarshal(headerJSON, &header); err != nil { + return claims, errors.New("token header is not JSON") + } + // Checked before the signature: accepting the token's own choice of algorithm is the + // JWT downgrade attack ("none", or an RS256 key confusion), and the message stays + // fixed rather than quoting the value back. + if header.Alg != "HS256" { + return claims, errors.New("token alg must be HS256") + } + + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return claims, errors.New("token signature is not base64url") + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(parts[0] + "." + parts[1])) + if !hmac.Equal(sig, mac.Sum(nil)) { // constant time + return claims, errors.New("token signature does not verify") + } + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return claims, errors.New("token payload is not base64url") + } + if err := json.Unmarshal(claimsJSON, &claims); err != nil { + return claims, errors.New("token payload is not JSON") + } + return claims, nil +} + +// validate checks the claim set against this instance and the current time. aud is the +// instance's BASE_URL: binding the token to one audience is what stops a token minted +// for a staging instance being spent on production, when both share a secret by mistake. +func (c ssoClaims) validate(aud string, now time.Time) error { + if c.Iss == "" { + return errors.New("iss is required") + } + if c.Aud == "" || c.Aud != aud { + return errors.New("aud does not match this instance") + } + // A full RFC 5322 parse is not the point; the value is looked up against a column + // whose contents are addresses, so this only rejects the obviously-not-an-address. + if s := strings.TrimSpace(c.Sub); s == "" || !strings.Contains(s, "@") { + return errors.New("sub must be an email address") + } + if strings.TrimSpace(c.Name) == "" { + return errors.New("name is required") + } + switch c.Role { + case "owner", "admin", "member": + default: + return errors.New("role must be owner, admin or member") + } + if c.JTI == "" { + return errors.New("jti is required") + } + if c.Iat == 0 { + return errors.New("iat is required") + } + if c.Exp == 0 { + return errors.New("exp is required") + } + + iat, exp := time.Unix(c.Iat, 0), time.Unix(c.Exp, 0) + if !exp.After(iat) { + return errors.New("exp must be after iat") + } + if exp.Sub(iat) > ssoMaxTokenLifetime { + return errors.New("exp is more than 60s after iat") + } + if iat.After(now.Add(ssoClockSkew)) { + return errors.New("iat is in the future") + } + if exp.Before(now.Add(-ssoClockSkew)) { + return errors.New("exp is in the past") + } + return nil +} diff --git a/internal/handler/sso_test.go b/internal/handler/sso_test.go new file mode 100644 index 0000000..b458dfb --- /dev/null +++ b/internal/handler/sso_test.go @@ -0,0 +1,358 @@ +package handler_test + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/handler" + "github.com/calnode/calnode/internal/uid" +) + +const ( + ssoSecret = "shared-secret-for-tests" + ssoBaseURL = "https://cal.example.test" +) + +// newSSOHandler returns a handler with the hand-off configured and its audience pinned. +func newSSOHandler(t *testing.T) (*handler.Handler, *db.DB) { + t.Helper() + h, database := newTestHandlerDB(t) + h.SetBaseURL(ssoBaseURL) + h.SetSSOSecret(ssoSecret) + return h, database +} + +// ssoToken mints a compact HS256 JWT the way an external identity system would. Kept +// independent of internal/handler's verifier on purpose: a test that signs with the +// production code would pass even if both halves were wrong in the same way. +func ssoToken(t *testing.T, secret string, claims map[string]any) string { + t.Helper() + enc := func(v any) string { + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return base64.RawURLEncoding.EncodeToString(b) + } + signingInput := enc(map[string]string{"alg": "HS256", "typ": "JWT"}) + "." + enc(claims) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingInput)) + return signingInput + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// ssoClaimSet is a valid claim set; cases mutate the one field they are about. +func ssoClaimSet() map[string]any { + now := time.Now().Unix() + return map[string]any{ + "iss": "identity.example.test", + "aud": ssoBaseURL, + "sub": "handed.over@example.test", + "name": "Handed Over", + "role": "member", + "iat": now, + "exp": now + 30, + "jti": uid.New(), + } +} + +func doSSO(h *handler.Handler, target string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.SSOHandoff(rec, httptest.NewRequest(http.MethodGet, target, nil)) + return rec +} + +func ssoErrorBody(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var body struct { + Error string `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode error body %q: %v", rec.Body.String(), err) + } + return body.Error +} + +func TestSSOHandoff_createsUserAndSession(t *testing.T) { + h, database := newSSOHandler(t) + + rec := doSSO(h, "/v1/auth/sso?token="+ssoToken(t, ssoSecret, ssoClaimSet())) + + if rec.Code != http.StatusFound { + t.Fatalf("status = %d; want 302 — %s", rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "/admin/" { + t.Errorf("Location = %q; want /admin/", loc) + } + + var userID string + var isAdmin, isOwner int + if err := database.QueryRow( + `SELECT id, is_admin, is_owner FROM users WHERE email = ?`, + "handed.over@example.test").Scan(&userID, &isAdmin, &isOwner); err != nil { + t.Fatalf("user was not created: %v", err) + } + if isAdmin != 0 || isOwner != 0 { + t.Errorf("role flags = admin %d owner %d; want 0 0 for a member claim", isAdmin, isOwner) + } + + // The session cookie must name a live session row for that user. + var cookie *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == "calnode_session" { + cookie = c + } + } + if cookie == nil { + t.Fatal("no calnode_session cookie was set") + } + var sessionUser string + if err := database.QueryRow( + `SELECT user_id FROM sessions WHERE id = ?`, cookie.Value).Scan(&sessionUser); err != nil { + t.Fatalf("session row: %v", err) + } + if sessionUser != userID { + t.Errorf("session belongs to %q; want %q", sessionUser, userID) + } +} + +// A claim asking for owner bootstraps ownership only when nobody holds it. The +// asymmetry is the point: see ssoResolveUser. +func TestSSOHandoff_ownerClaimBootstrapsOnlyWhenUnowned(t *testing.T) { + h, database := newSSOHandler(t) + + first := ssoClaimSet() + first["sub"] = "first.owner@example.test" + first["role"] = "owner" + if rec := doSSO(h, "/v1/auth/sso?token="+ssoToken(t, ssoSecret, first)); rec.Code != http.StatusFound { + t.Fatalf("first hand-off: status = %d — %s", rec.Code, rec.Body.String()) + } + + second := ssoClaimSet() + second["sub"] = "second.owner@example.test" + second["role"] = "owner" + if rec := doSSO(h, "/v1/auth/sso?token="+ssoToken(t, ssoSecret, second)); rec.Code != http.StatusFound { + t.Fatalf("second hand-off: status = %d — %s", rec.Code, rec.Body.String()) + } + + var owners int + if err := database.QueryRow(`SELECT COUNT(*) FROM users WHERE is_owner = 1`).Scan(&owners); err != nil { + t.Fatalf("count owners: %v", err) + } + if owners != 1 { + t.Errorf("owners = %d; want exactly 1", owners) + } + var isAdmin, isOwner int + if err := database.QueryRow(`SELECT is_admin, is_owner FROM users WHERE email = ?`, + "second.owner@example.test").Scan(&isAdmin, &isOwner); err != nil { + t.Fatalf("second user: %v", err) + } + if isOwner != 0 || isAdmin != 1 { + t.Errorf("second user = admin %d owner %d; want admin 1 owner 0", isAdmin, isOwner) + } +} + +// An existing user's role is not rewritten by a hand-off (owner-bootstrap aside). +func TestSSOHandoff_doesNotDemoteAnExistingUser(t *testing.T) { + h, database, _, ownerID := setupWorkspaceWithDB(t) + h.SetBaseURL(ssoBaseURL) + h.SetSSOSecret(ssoSecret) + + var email string + if err := database.QueryRow(`SELECT email FROM users WHERE id = ?`, ownerID).Scan(&email); err != nil { + t.Fatalf("load owner email: %v", err) + } + + claims := ssoClaimSet() + claims["sub"] = email + claims["role"] = "member" + if rec := doSSO(h, "/v1/auth/sso?token="+ssoToken(t, ssoSecret, claims)); rec.Code != http.StatusFound { + t.Fatalf("status = %d — %s", rec.Code, rec.Body.String()) + } + + var isAdmin, isOwner int + if err := database.QueryRow(`SELECT is_admin, is_owner FROM users WHERE id = ?`, ownerID). + Scan(&isAdmin, &isOwner); err != nil { + t.Fatalf("reload owner: %v", err) + } + if isAdmin != 1 || isOwner != 1 { + t.Errorf("owner = admin %d owner %d; want 1 1 (a member claim must not demote)", isAdmin, isOwner) + } +} + +func TestSSOHandoff_replayedJTIIsRejected(t *testing.T) { + h, _ := newSSOHandler(t) + token := ssoToken(t, ssoSecret, ssoClaimSet()) + + if rec := doSSO(h, "/v1/auth/sso?token="+token); rec.Code != http.StatusFound { + t.Fatalf("first use: status = %d — %s", rec.Code, rec.Body.String()) + } + rec := doSSO(h, "/v1/auth/sso?token="+token) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("replay: status = %d; want 401", rec.Code) + } + if got := ssoErrorBody(t, rec); got != "jti has already been used" { + t.Errorf("error = %q; want the jti to be named", got) + } +} + +func TestSSOHandoff_rejectsBadTokens(t *testing.T) { + valid := ssoClaimSet() + + expired := ssoClaimSet() + expired["iat"] = time.Now().Add(-10 * time.Minute).Unix() + expired["exp"] = time.Now().Add(-10 * time.Minute).Add(30 * time.Second).Unix() + + longLived := ssoClaimSet() + longLived["exp"] = longLived["iat"].(int64) + 3600 + + wrongAud := ssoClaimSet() + wrongAud["aud"] = "https://other.example.test" + + noIss := ssoClaimSet() + noIss["iss"] = "" + + badRole := ssoClaimSet() + badRole["role"] = "superuser" + + cases := []struct { + name string + token string + wantError string + }{ + {"expired", ssoToken(t, ssoSecret, expired), "exp is in the past"}, + {"lifetime too long", ssoToken(t, ssoSecret, longLived), "exp is more than 60s after iat"}, + {"wrong aud", ssoToken(t, ssoSecret, wrongAud), "aud does not match this instance"}, + {"missing iss", ssoToken(t, ssoSecret, noIss), "iss is required"}, + {"bad role", ssoToken(t, ssoSecret, badRole), "role must be owner, admin or member"}, + {"bad signature", ssoToken(t, "not-the-shared-secret", valid), "token signature does not verify"}, + {"not a jwt", "nonsense", "token is not a three-part JWT"}, + {"no token", "", "token is required"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h, database := newSSOHandler(t) + rec := doSSO(h, "/v1/auth/sso?token="+tc.token) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d; want 401 — %s", rec.Code, rec.Body.String()) + } + if got := ssoErrorBody(t, rec); got != tc.wantError { + t.Errorf("error = %q; want %q", got, tc.wantError) + } + var users int + if err := database.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil { + t.Fatalf("count users: %v", err) + } + if users != 0 { + t.Errorf("users = %d; a rejected token must not create anyone", users) + } + }) + } +} + +// An "alg": "none" token with an empty signature is the classic downgrade. It must be +// refused on the algorithm, before the signature is even compared. +func TestSSOHandoff_rejectsAlgNone(t *testing.T) { + h, _ := newSSOHandler(t) + enc := func(v any) string { + b, _ := json.Marshal(v) + return base64.RawURLEncoding.EncodeToString(b) + } + token := enc(map[string]string{"alg": "none", "typ": "JWT"}) + "." + enc(ssoClaimSet()) + "." + + rec := doSSO(h, "/v1/auth/sso?token="+token) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d; want 401", rec.Code) + } + if got := ssoErrorBody(t, rec); got != "token alg must be HS256" { + t.Errorf("error = %q; want the alg to be named", got) + } +} + +func TestSSOHandoff_nextMustBeSameOriginPath(t *testing.T) { + cases := []struct { + name string + next string + }{ + {"absolute url", "https://evil.example.test/"}, + {"protocol relative", "//evil.example.test/"}, + {"backslash", `/\evil.example.test`}, + {"scheme inside", "/redirect?to=https://evil.example.test"}, + {"not a path", "admin/"}, + {"header injection", "/admin/\r\nX-Injected: 1"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h, database := newSSOHandler(t) + token := ssoToken(t, ssoSecret, ssoClaimSet()) + rec := doSSO(h, "/v1/auth/sso?token="+token+"&next="+url.QueryEscape(tc.next)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d; want 400 — %s", rec.Code, rec.Body.String()) + } + // The token was refused before its nonce was claimed, so the caller can + // retry the same one with a sane next. + var nonces int + if err := database.QueryRow(`SELECT COUNT(*) FROM sso_nonces`).Scan(&nonces); err != nil { + t.Fatalf("count nonces: %v", err) + } + if nonces != 0 { + t.Errorf("nonces = %d; a bad next must not burn the token", nonces) + } + }) + } +} + +func TestSSOHandoff_nextHonoursALocalPath(t *testing.T) { + h, _ := newSSOHandler(t) + rec := doSSO(h, "/v1/auth/sso?token="+ssoToken(t, ssoSecret, ssoClaimSet())+"&next=%2Fadmin%2Fbookings") + if rec.Code != http.StatusFound { + t.Fatalf("status = %d; want 302 — %s", rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "/admin/bookings" { + t.Errorf("Location = %q; want /admin/bookings", loc) + } +} + +// Unset CALNODE_SSO_SHARED_SECRET ⇒ 404, indistinguishable from a build without the +// feature. Documented in DEPLOY.md and ARCHITECTURE.md §6. +func TestSSOHandoff_disabledWithoutASecret(t *testing.T) { + h, _ := newTestHandlerDB(t) + h.SetBaseURL(ssoBaseURL) + + rec := doSSO(h, "/v1/auth/sso?token="+ssoToken(t, ssoSecret, ssoClaimSet())) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d; want 404 — %s", rec.Code, rec.Body.String()) + } +} + +func TestSSOHandoff_archivedUserCannotSignIn(t *testing.T) { + h, database, _, ownerID := setupWorkspaceWithDB(t) + h.SetBaseURL(ssoBaseURL) + h.SetSSOSecret(ssoSecret) + + var email string + if err := database.QueryRow(`SELECT email FROM users WHERE id = ?`, ownerID).Scan(&email); err != nil { + t.Fatalf("load owner email: %v", err) + } + if _, err := database.Exec(`UPDATE users SET archived_at = ? WHERE id = ?`, + time.Now().UTC().Format(time.RFC3339), ownerID); err != nil { + t.Fatalf("archive user: %v", err) + } + + claims := ssoClaimSet() + claims["sub"] = email + rec := doSSO(h, "/v1/auth/sso?token="+ssoToken(t, ssoSecret, claims)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d; want 401 — %s", rec.Code, rec.Body.String()) + } + if got := ssoErrorBody(t, rec); got != "account is archived" { + t.Errorf("error = %q; want the archive to be named", got) + } +} diff --git a/internal/handler/storage_settings.go b/internal/handler/storage_settings.go index 9254e6f..e636ced 100644 --- a/internal/handler/storage_settings.go +++ b/internal/handler/storage_settings.go @@ -5,6 +5,8 @@ import ( "net/http" "os" "strings" + + "github.com/calnode/calnode/internal/dbtime" ) // The Storage settings page surfaces both object-storage uses in one place: the Litestream DB @@ -54,7 +56,7 @@ func (h *Handler) PatchStorageSettings(w http.ResponseWriter, r *http.Request) { v = 1 } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET recordings_enabled = ?, updated_at = datetime('now') WHERE id = 1`, v); err != nil { + `UPDATE server_settings SET recordings_enabled = ?, updated_at = ? WHERE id = 1`, v, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "storage settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/stripe_locale_internal_test.go b/internal/handler/stripe_locale_internal_test.go index efb4b64..633b9ac 100644 --- a/internal/handler/stripe_locale_internal_test.go +++ b/internal/handler/stripe_locale_internal_test.go @@ -12,7 +12,7 @@ import ( "time" "github.com/calnode/calnode/internal/booking" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/mailer" ) @@ -65,14 +65,7 @@ func (c *captureMailer) recipients() []string { // of the language the attendee actually booked in — even though the free-booking path (same // dispatchBookingConfirmation) got this right. See stripe_booking.go's SELECT. func TestConfirmPaidBooking_usesAttendeeLocale(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) h := New(database, slog.Default()) cap := &captureMailer{} diff --git a/internal/handler/stripe_settings.go b/internal/handler/stripe_settings.go index 6c65e35..2931f35 100644 --- a/internal/handler/stripe_settings.go +++ b/internal/handler/stripe_settings.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/stripe" ) @@ -19,7 +21,7 @@ type StripeConfig struct { // LoadStripeSettingsFromDB reads Stripe credentials from server_settings and decrypts the // secret key + webhook secret. Returns nil (not an error) when the secret key is unset. -func LoadStripeSettingsFromDB(db *sql.DB, encKey [32]byte) (*StripeConfig, error) { +func LoadStripeSettingsFromDB(db *db.DB, encKey [32]byte) (*StripeConfig, error) { var secretEnc, pubKey, whEnc string err := db.QueryRow(` SELECT stripe_secret_key_enc, stripe_publishable_key, stripe_webhook_secret_enc @@ -97,8 +99,8 @@ func (h *Handler) PatchStripeSettings(w http.ResponseWriter, r *http.Request) { if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET stripe_secret_key_enc = '', stripe_publishable_key = '', stripe_webhook_secret_enc = '', - updated_at = datetime('now') - WHERE id = 1`); err != nil { + updated_at = ? + WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "stripe settings: clear", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -111,8 +113,8 @@ func (h *Handler) PatchStripeSettings(w http.ResponseWriter, r *http.Request) { // Update publishable key (plain — it's a public value) when provided. if req.PublishableKey != nil { if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET stripe_publishable_key = ?, updated_at = datetime('now') WHERE id = 1`, - *req.PublishableKey); err != nil { + `UPDATE server_settings SET stripe_publishable_key = ?, updated_at = ? WHERE id = 1`, + *req.PublishableKey, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "stripe settings: update pub key", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -127,7 +129,7 @@ func (h *Handler) PatchStripeSettings(w http.ResponseWriter, r *http.Request) { return } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET stripe_secret_key_enc = ?, updated_at = datetime('now') WHERE id = 1`, enc); err != nil { + `UPDATE server_settings SET stripe_secret_key_enc = ?, updated_at = ? WHERE id = 1`, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "stripe settings: update secret key", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -141,7 +143,7 @@ func (h *Handler) PatchStripeSettings(w http.ResponseWriter, r *http.Request) { return } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET stripe_webhook_secret_enc = ?, updated_at = datetime('now') WHERE id = 1`, enc); err != nil { + `UPDATE server_settings SET stripe_webhook_secret_enc = ?, updated_at = ? WHERE id = 1`, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "stripe settings: update webhook secret", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/teams.go b/internal/handler/teams.go index ff32173..c096625 100644 --- a/internal/handler/teams.go +++ b/internal/handler/teams.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) @@ -86,7 +87,7 @@ func (h *Handler) CreateTeam(w http.ResponseWriter, r *http.Request) { if _, err := h.db.ExecContext(r.Context(), `INSERT INTO teams (id, name, slug, created_at) VALUES (?, ?, ?, ?)`, id, req.Name, slug, now); err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "a team with that slug already exists") return } @@ -222,7 +223,7 @@ func (h *Handler) PatchTeam(w http.ResponseWriter, r *http.Request) { res, err := h.db.ExecContext(r.Context(), "UPDATE teams SET "+strings.Join(sets, ", ")+" WHERE id = ?", args...) // #nosec G202 -- sets is built above from hardcoded "col = ?" literals only; every value is bound via args... if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "a team with that slug already exists") return } @@ -306,7 +307,7 @@ func (h *Handler) AddTeamMember(w http.ResponseWriter, r *http.Request) { INSERT INTO team_members (id, team_id, user_id, role, routing_priority) VALUES (?, ?, ?, 'member', ?)`, uid.New(), teamID, req.UserID, req.RoutingPriority); err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "that user is already in this team") return } diff --git a/internal/handler/tracking_settings.go b/internal/handler/tracking_settings.go index 9a29f77..87a3e9a 100644 --- a/internal/handler/tracking_settings.go +++ b/internal/handler/tracking_settings.go @@ -6,6 +6,8 @@ import ( "net/http" "regexp" "strings" + + "github.com/calnode/calnode/internal/dbtime" ) // Google tag ID formats. The ID is interpolated into a