aboutsummaryrefslogtreecommitdiff
path: root/docs/superpowers/specs
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 21:18:47 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 21:18:47 +0200
commit512e6681418c354fea02761440e701c0f7ade693 (patch)
treeb2e9e75b330b0f9f3f7d4499d994c2d5119f18cd /docs/superpowers/specs
parent706800b6635dd2327ccc91107795d1d6a663d27e (diff)
downloadlectio-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/superpowers/specs')
-rw-r--r--docs/superpowers/specs/2026-07-27-lectio-calendar-api-design.md125
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.