aboutsummaryrefslogtreecommitdiff
path: root/docs/superpowers/specs/2026-07-27-lectio-calendar-api-design.md
blob: 175816de462295cd14c45c3f1fcdf2615b7b7346 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
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.