diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-27 21:18:47 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-27 21:18:47 +0200 |
| commit | 512e6681418c354fea02761440e701c0f7ade693 (patch) | |
| tree | b2e9e75b330b0f9f3f7d4499d994c2d5119f18cd /docs | |
| parent | 706800b6635dd2327ccc91107795d1d6a663d27e (diff) | |
| download | lectio-512e6681418c354fea02761440e701c0f7ade693.tar.gz lectio-512e6681418c354fea02761440e701c0f7ade693.zip | |
docs: design spec for calendar API (JSON + iCal), security-first
Two surfaces, one engine: CLI --format json|ical (offline/pipeable) + thin
lectio-web /api/calendar.json + /calendar.ics endpoints. Pure internal/calfeed
renderer. Identity + reading citations (no full text). Security section:
range-cap DoS control (1830d web), RFC-5545 iCal injection escaping (custom
calendar names are untrusted), strict input validation, nosniff content types,
no file/layer injection over HTTP, GET-only.
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/specs/2026-07-27-lectio-calendar-api-design.md | 125 |
1 files changed, 125 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-07-27-lectio-calendar-api-design.md b/docs/superpowers/specs/2026-07-27-lectio-calendar-api-design.md new file mode 100644 index 0000000..175816d --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-lectio-calendar-api-design.md @@ -0,0 +1,125 @@ +# Calendar API (JSON + iCal) Implementation Design + +**Goal:** Expose lectio's computed liturgical calendar (day identity + reading citations) as JSON and iCal, over two surfaces sharing one engine: CLI emitters (`lectio --format json|ical`, offline/pipeable) and thin `lectio-web` HTTP endpoints (`/api/calendar.json`, `/calendar.ics`) that others can subscribe to. + +**Architecture:** A pure `internal/calfeed` package renders a `[]calendar.LiturgicalDay` (+ per-day readings) into JSON or iCal bytes. Both the CLI and the web handlers build the day list with the existing engine (`calendar.Compute` + `caldata.Stack` + the shared reading resolver) and hand it to `calfeed`. All request parsing, range-capping, and escaping live in one place so both surfaces get the same validated, injection-safe output. + +**Tech Stack:** Go stdlib only (`encoding/json`, `net/http`, `time`, `strings`); no third-party deps. Reuses `internal/calendar`, `internal/caldata`. + +## Global Constraints + +- **Stdlib-only, pure `calfeed`:** `internal/calfeed` imports only stdlib + `internal/calendar`. JSON via `encoding/json.Marshal` (never string concatenation). iCal built with explicit RFC-5545 escaping + line folding. +- **Both surfaces reuse one builder and one renderer** — no duplicated compute or formatting logic between CLI and web. +- **Form + layers:** the day list is built via the config's `Selection()` (form old/new, movable-feast knobs) and `caldata.Stack(form, dir, use)` custom layers, identical to `--liturgy`. `--form`/`?form=` may override the form only (enum-validated). +- **Reading citations only** — never full scripture text (keeps payloads lean, avoids the copyrighted `bt` text). Citations come from the same resolver `--liturgy` uses. +- **Security is a build requirement, not an add-on** — see the Security section; every text value that reaches iCal is escaped, every range is capped, every input is validated before compute. +- **Non-breaking:** existing web routes, the HTML `/calendar` view, and all CLI subcommands keep working. GET-only endpoints; no state mutation. +- **Stable JSON schema:** the payload carries a `"schema": "lectio.calendar/1"` string; field names are frozen for v1. + +## Detailed Design + +### 1. Surfaces + +**CLI** (extends the existing `--liturgy` path in `internal/cli`): +- `lectio [DATE] --format json|ical` — single day (default today). +- `lectio --from YYYY-MM-DD --to YYYY-MM-DD --format json|ical` — inclusive range. +- `lectio --year YYYY --format json|ical` — sugar for Jan 1–Dec 31 of YYYY. +- `--form old|new` overrides the config form. +- Output goes to stdout (redirect to a `.json`/`.ics` file). Errors → stderr, non-zero exit. + +**Web** (`internal/web`, new handlers on the existing mux): +- `GET /api/calendar.json?date=YYYY-MM-DD` or `?from=&to=` or `?year=` (+ optional `&form=old|new`). +- `GET /calendar.ics?from=&to=` or `?year=` (+ optional `&form=`) — served as `text/calendar` for `webcal://` subscription. +- Both use the server's configured form/layers; per-request layer selection is NOT accepted over HTTP (see Security). + +### 2. Request model & validation + +- Dates parsed strictly with `time.Parse("2006-01-02", s)`; any parse error → 400 (web) / exit 2 (CLI) with a fixed message. +- `form` ∈ {`old`,`new`}; anything else rejected (not defaulted silently) on the web; CLI mirrors. +- `year` parsed as int, constrained to **1583–9999** (the Gregorian Easter/Computus valid domain; years ≤ 1582 predate the Gregorian reform and are rejected). +- Range rules: `from` ≤ `to`; the inclusive span is capped (see Security D1). A single `date` is treated as `from == to`. +- Exactly one of {`date`, (`from`&`to`), `year`} is accepted; ambiguous/empty combinations → 400. + +### 3. JSON schema (`lectio.calendar/1`) + +```json +{ + "schema": "lectio.calendar/1", + "form": "old", + "days": [ + { + "date": "2026-01-06", + "season": "time-after-epiphany", + "week": 1, + "weekday": "Tuesday", + "colour": "white", + "observed": { + "slug": "ef-epiphany", + "name": "The Epiphany of the Lord", + "rank": "class-1", + "class": 1 + }, + "others": [ + { "slug": "…", "name": "…", "rank": "commemoration", "class": 0 } + ], + "cycles": { "sunday": "C", "weekday": "II" }, + "readings": [ + { "part": "first", "citation": "Isa 60:1-6" }, + { "part": "gospel", "citation": "Matt 2:1-12" } + ] + } + ] +} +``` + +- `name` is the celebration name in the configured UI language, falling back to English then a humanized slug (same rule as `--liturgy`). +- `cycles` is populated for the OF; both fields are empty strings for the EF (which has no reading cycles). The keys are always present for a stable shape. +- `readings` is `[]` when none resolve. Encoded via dedicated JSON structs with fixed `json:"…"` tags (not the raw `calendar` types), so the wire format is decoupled from internal fields and stable across refactors. + +### 4. iCal format (RFC 5545) + +- One all-day `VEVENT` per day: `DTSTART;VALUE=DATE:YYYYMMDD`, `DTEND;VALUE=DATE:<next day>`. +- `SUMMARY` = celebration name. `DESCRIPTION` = season + week, rank, colour, and reading citations (one per line, joined with escaped `\n`). `CATEGORIES` = liturgical colour (e.g. `WHITE`) for app-side colouring/filtering. +- `UID` = `<date>-<form>@lectio` — stable and input-free, so re-subscribing updates events in place rather than duplicating. +- `DTSTAMP` = the generation time (`time.Now().UTC()`), formatted `20060102T150405Z`. +- Calendar-level: `BEGIN:VCALENDAR`, `VERSION:2.0`, `PRODID:-//lectio//calendar//EN`, `X-WR-CALNAME` = `Lectio — Extraordinary Form` / `… — Ordinary Form`, `X-WR-CALDESC`. +- Every text value passes through `icalEscape` (Security D2) and lines are folded at 75 octets per RFC 5545, with continuation lines beginning with a single space; folding runs AFTER escaping and counts octets, not runes. + +### 5. Day building (shared) + +- A single helper builds `[]DayView` from `(from, to, sel, layers)` by iterating dates and calling `calendar.Compute`, attaching readings via the existing resolver (`dayReadings` in `internal/cli/liturgy.go`). To share it, the reading-resolution helper is lifted into a small exported function (e.g. `caldata` or a shared `liturgy` helper) so both CLI and web call the same code — no divergence, and the readings match `--liturgy` exactly. + +## Security + +The JSON emitter is low-risk (structured `encoding/json`), but the **iCal emitter and the web endpoints are the real surface**. Controls, each testable: + +- **D1 — Range cap (resource-exhaustion / DoS):** the inclusive day span is capped at **1830 days (≈5 years)** on the WEB endpoints; a larger range → 400 with a fixed message, before any compute. This bounds CPU and response size (compute is O(days)). The CLI (local, trusted) uses a generous sanity cap of 100 years to catch typos without limiting legitimate bulk generation. No request can trigger an unbounded loop or allocation. +- **D2 — iCal injection (the key control):** celebration names and citations can originate in **user-editable custom calendars** (config data), so they are untrusted for output purposes. `icalEscape` escapes per RFC 5545 — `\` → `\\`, `;` → `\;`, `,` → `\,`, and CR/LF → `\n` — so a crafted name (`"Foo\nBEGIN:VEVENT…"`) cannot inject properties or events. All CR/LF are neutralized; no raw newline ever reaches an iCal line. Applied to SUMMARY, DESCRIPTION, CATEGORIES, and all X- props. `UID`/`DTSTAMP` are input-free. +- **D3 — Strict input validation:** dates via `time.Parse` only; `form` enum-checked; `year` bounded 1583–9999; range order enforced. Invalid input is rejected up front (400 / exit 2) with **fixed** messages that do not echo raw request input (avoids any reflected-content vector), and never reaches the compute path. +- **D4 — Correct content types + no sniffing:** `application/json; charset=utf-8` and `text/calendar; charset=utf-8`; both responses set `X-Content-Type-Options: nosniff` so a browser cannot reinterpret a feed as HTML/JS. Responses contain data only — no HTML, no reflected script. +- **D5 — No file-path or layer injection over HTTP:** the web endpoints accept only `date`/`from`/`to`/`year`/`form`; they never take a calendar name, file path, or `use=` list from the request. The server computes with its own configured layers, so a request cannot cause it to read arbitrary files. (CLI custom layers come from the local config/`use=`, unchanged.) +- **D6 — GET-only, no mutation, public data:** endpoints are read-only (`GET` patterns on the mux), so there is no CSRF/state surface; the calendar is public data, so no auth or secret handling is introduced. +- **D7 — Bounded output:** because the day count is capped (D1) and each day emits a fixed-size record, total response size is bounded; no streaming-unbounded or memory-amplification path exists. + +Explicitly **out of scope** (documented, not silently dropped): rate-limiting / per-IP throttling (deployment concern; the range cap is the in-app DoS control), TLS termination (reverse-proxy concern), and CORS headers (add later if browser cross-origin use is wanted). + +## Testing Approach + +- `internal/calfeed`: table tests for JSON (golden payloads, stable field order via structs), iCal (VEVENT structure, DTSTART/DTEND, UID), and **security**: an `icalEscape` test with `;,\` and embedded CR/LF asserting no unescaped newline survives and no `BEGIN:`/`END:` injection is possible from a malicious celebration name; a folding test at the 75-octet boundary. +- `internal/cli`: `--format json|ical` single/`--from`/`--to`/`--year`; bad date → exit 2; the CLI sanity cap. +- `internal/web`: handler tests for 200 + correct Content-Type + `nosniff`; 400 on bad date, bad form, inverted range, and **over-cap range**; a JSON round-trip parse; an iCal response parses as one VEVENT per day. +- Determinism: `DTSTAMP` (the only time-dependent field) is injected via a clock parameter in tests so golden files are stable. + +## Out of Scope (YAGNI) + +- Full scripture text in feeds (citations only). +- OF readings (they arrive in a later sub-project and flow into the API automatically through the shared resolver — no API change needed). +- Auth, API keys, rate-limiting beyond the range cap, CORS, TLS (deployment concerns). +- Per-request custom calendar layers over HTTP. +- Historical-response caching / ETags (can be added later; output is deterministic so it is cache-friendly). + +## Open Decisions (resolved) + +- **Range cap:** 1830 days (~5 years) on the web; 100-year sanity cap on the CLI. +- **iCal colour:** `CATEGORIES` (widely supported); the RFC 7986 `COLOR` property is omitted (spotty support) — can be added later without breaking consumers. +- **JSON envelope:** keep the `{schema, form, days:[…]}` wrapper (carries the schema version + form context) rather than a bare array. |
