aboutsummaryrefslogtreecommitdiff
path: root/docs/superpowers/plans/2026-07-27-lectio-calendar-api.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-07-27-lectio-calendar-api.md')
-rw-r--r--docs/superpowers/plans/2026-07-27-lectio-calendar-api.md409
1 files changed, 409 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-27-lectio-calendar-api.md b/docs/superpowers/plans/2026-07-27-lectio-calendar-api.md
new file mode 100644
index 0000000..f778ee8
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-27-lectio-calendar-api.md
@@ -0,0 +1,409 @@
+# Calendar API (JSON + iCal) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Expose the computed liturgical calendar (identity + reading citations) as JSON and iCal via CLI emitters (`lectio --format json|ical`) and thin `lectio-web` endpoints (`/api/calendar.json`, `/calendar.ics`), sharing one pure renderer.
+
+**Architecture:** A new stdlib-only `internal/calfeed` package turns a `[]calfeed.DayView` into JSON or iCal bytes. A shared builder computes the day list (`calendar.Compute` + the lifted reading resolver). CLI and web both build → render. All validation, range-capping, and RFC-5545 escaping live in one place.
+
+**Spec:** `docs/superpowers/specs/2026-07-27-lectio-calendar-api-design.md`
+
+## Global Constraints
+
+- **Pure `internal/calfeed`:** imports only stdlib + `internal/calendar`. JSON via `encoding/json.Marshal` (never string concat). iCal built with explicit escaping + folding.
+- **Reading citations only**, never scripture text. Citations come from the same resolver `--liturgy` uses.
+- **Form + layers** via the config's `Selection()` + `caldata.Stack`; `--form`/`?form=` overrides the form ONLY, enum-validated {`old`,`new`}.
+- **Security (spec §Security) is required, not optional:** RFC-5545 escape every text value reaching iCal; cap the web day span at 1830 days and reject before compute; validate every input up front with fixed messages; set correct Content-Type + `X-Content-Type-Options: nosniff`; GET-only; no file/layer params over HTTP.
+- **Stable JSON:** `{"schema":"lectio.calendar/1","form":…,"days":[…]}`; dedicated structs with frozen `json:"…"` tags (not raw `calendar` types).
+- **Determinism for tests:** the only time-dependent value (`DTSTAMP`) is injected, not read from the wall clock inside the renderer.
+- **Non-breaking:** existing routes/subcommands unchanged; every task ends with `gofmt -l` clean and `go test ./...` green.
+
+**Verified facts (use as written):**
+- `calendar.LiturgicalDay{ Date time.Time; Season Season; Week int; Weekday time.Weekday; Observed Celebration; Others []Celebration; Colour Colour; ObservedBand int; SundayCycle, WeekdayCycle string }`.
+- `calendar.Celebration{ Slug string; Name map[string]string; Rank Rank; Class Class; Colour Colour; … }`; `calendar.Reading{ Part, Citation string }`.
+- Web mux is Go 1.22 method-pattern: `mux.HandleFunc("GET /calendar", …)` in `internal/web/server.go` `NewServer`. Existing `GET /calendar` is the HTML view; new paths `/api/calendar.json` and `/calendar.ics` do NOT collide.
+- Reading resolution today lives in `internal/cli/liturgy.go` `dayReadings(sel, layers, date, day)` and `celebrationName(cfg, cel)`.
+
+---
+
+### Task 1: `calfeed` day model + JSON renderer
+
+**Files:**
+- Create: `internal/calfeed/calfeed.go`, `internal/calfeed/json.go`
+- Test: `internal/calfeed/json_test.go`
+
+**Interfaces:**
+- Produces: `type DayView struct{…}`, `type CelView struct{…}`, `type ReadingView struct{ Part, Citation string }`; `func JSON(form string, days []DayView) ([]byte, error)`.
+
+- [ ] **Step 1: Define the wire model** in `internal/calfeed/calfeed.go` (decoupled from `calendar` types, frozen tags):
+
+```go
+package calfeed
+
+type ReadingView struct {
+ Part string `json:"part"`
+ Citation string `json:"citation"`
+}
+
+type CelView struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Rank string `json:"rank"`
+ Class int `json:"class"`
+}
+
+type DayView struct {
+ Date string `json:"date"` // YYYY-MM-DD
+ Season string `json:"season"`
+ Week int `json:"week"`
+ Weekday string `json:"weekday"`
+ Colour string `json:"colour"`
+ Observed CelView `json:"observed"`
+ Others []CelView `json:"others"`
+ Cycles Cycles `json:"cycles"`
+ Readings []ReadingView `json:"readings"`
+}
+
+type Cycles struct {
+ Sunday string `json:"sunday"`
+ Weekday string `json:"weekday"`
+}
+```
+
+- [ ] **Step 2: Write the failing JSON test** `internal/calfeed/json_test.go`:
+
+```go
+package calfeed
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestJSONShape(t *testing.T) {
+ days := []DayView{{
+ Date: "2026-01-06", Season: "time-after-epiphany", Week: 1,
+ Weekday: "Tuesday", Colour: "white",
+ Observed: CelView{Slug: "ef-epiphany", Name: "The Epiphany of the Lord", Rank: "class-1", Class: 1},
+ Others: []CelView{},
+ Cycles: Cycles{},
+ Readings: []ReadingView{{Part: "gospel", Citation: "Matt 2:1-12"}},
+ }}
+ b, err := JSON("old", days)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out struct {
+ Schema string `json:"schema"`
+ Form string `json:"form"`
+ Days []DayView `json:"days"`
+ }
+ if err := json.Unmarshal(b, &out); err != nil {
+ t.Fatal(err)
+ }
+ if out.Schema != "lectio.calendar/1" || out.Form != "old" || len(out.Days) != 1 {
+ t.Fatalf("bad envelope: %s", b)
+ }
+ if out.Days[0].Observed.Name != "The Epiphany of the Lord" {
+ t.Fatalf("bad day: %s", b)
+ }
+}
+```
+
+- [ ] **Step 3: Run it, verify it fails** — `go test ./internal/calfeed/` → FAIL (undefined JSON).
+
+- [ ] **Step 4: Implement** `internal/calfeed/json.go`:
+
+```go
+package calfeed
+
+import "encoding/json"
+
+const Schema = "lectio.calendar/1"
+
+// JSON renders days as the stable lectio.calendar/1 envelope.
+func JSON(form string, days []DayView) ([]byte, error) {
+ if days == nil {
+ days = []DayView{}
+ }
+ return json.MarshalIndent(struct {
+ Schema string `json:"schema"`
+ Form string `json:"form"`
+ Days []DayView `json:"days"`
+ }{Schema, form, days}, "", " ")
+}
+```
+
+- [ ] **Step 5: Run tests + gofmt** — `go test ./internal/calfeed/` PASS; `gofmt -l internal/calfeed/`.
+
+- [ ] **Step 6: Commit** — `git commit -m "feat(calfeed): day wire model + JSON renderer"`
+
+---
+
+### Task 2: iCal renderer + RFC-5545 escaping (security-critical)
+
+**Files:**
+- Create: `internal/calfeed/ical.go`
+- Test: `internal/calfeed/ical_test.go`
+
+**Interfaces:**
+- Consumes: `DayView` (Task 1).
+- Produces: `func ICal(form string, days []DayView, stamp time.Time) []byte`; unexported `icalEscape(string) string`, `foldLine(string) string`.
+
+- [ ] **Step 1: Write the failing SECURITY test** `internal/calfeed/ical_test.go` (write the escaping/injection test FIRST — it is the point of this task):
+
+```go
+package calfeed
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestICalEscapeInjection(t *testing.T) {
+ // A malicious custom-calendar name must not be able to inject lines/props.
+ got := icalEscape("Evil\r\nBEGIN:VEVENT\nSUMMARY:hijack; a,b\\c")
+ if strings.ContainsAny(got, "\r\n") {
+ t.Fatalf("unescaped newline survived: %q", got)
+ }
+ for _, sub := range []string{`\n`, `\;`, `\,`, `\\`} {
+ if !strings.Contains(got, sub) {
+ t.Fatalf("missing escape %q in %q", sub, got)
+ }
+ }
+}
+
+func TestICalStructure(t *testing.T) {
+ days := []DayView{{
+ Date: "2026-01-06", Season: "time-after-epiphany", Week: 1, Colour: "white",
+ Observed: CelView{Name: "The Epiphany of the Lord", Rank: "class-1"},
+ Readings: []ReadingView{{Part: "gospel", Citation: "Matt 2:1-12"}},
+ }}
+ out := string(ICal("old", days, time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)))
+ for _, want := range []string{
+ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//lectio//calendar//EN",
+ "BEGIN:VEVENT", "UID:2026-01-06-old@lectio", "DTSTART;VALUE=DATE:20260106",
+ "DTEND;VALUE=DATE:20260107", "SUMMARY:The Epiphany of the Lord",
+ "CATEGORIES:WHITE", "DTSTAMP:20260727T120000Z", "END:VEVENT", "END:VCALENDAR",
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("missing %q in:\n%s", want, out)
+ }
+ }
+ // injection attempt via day count: exactly one VEVENT
+ if strings.Count(out, "BEGIN:VEVENT") != 1 {
+ t.Fatalf("expected 1 VEVENT")
+ }
+}
+
+func TestFoldLine(t *testing.T) {
+ long := "SUMMARY:" + strings.Repeat("x", 200)
+ for _, line := range strings.Split(foldLine(long), "\r\n") {
+ if len(line) > 75 {
+ t.Fatalf("line exceeds 75 octets: %d", len(line))
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run it, verify it fails** — `go test ./internal/calfeed/ -run ICal` → FAIL.
+
+- [ ] **Step 3: Implement** `internal/calfeed/ical.go`:
+
+```go
+package calfeed
+
+import (
+ "strconv"
+ "strings"
+ "time"
+)
+
+// icalEscape neutralises RFC-5545 TEXT specials AND all CR/LF, so untrusted
+// celebration names / citations cannot inject iCal lines or properties.
+func icalEscape(s string) string {
+ s = strings.ReplaceAll(s, "\\", "\\\\")
+ s = strings.ReplaceAll(s, ";", "\\;")
+ s = strings.ReplaceAll(s, ",", "\\,")
+ s = strings.ReplaceAll(s, "\r\n", "\\n")
+ s = strings.ReplaceAll(s, "\r", "\\n")
+ s = strings.ReplaceAll(s, "\n", "\\n")
+ return s
+}
+
+// foldLine folds a content line at 75 octets with a leading space on
+// continuations (RFC 5545 §3.1). Counts bytes; folding runs after escaping.
+func foldLine(line string) string {
+ if len(line) <= 75 {
+ return line
+ }
+ var b strings.Builder
+ for i := 0; i < len(line); {
+ end := i + 75
+ if i > 0 {
+ end = i + 74 // account for the leading space
+ }
+ if end > len(line) {
+ end = len(line)
+ }
+ if i > 0 {
+ b.WriteString("\r\n ")
+ }
+ b.WriteString(line[i:end])
+ i = end
+ }
+ return b.String()
+}
+
+func calName(form string) string {
+ if form == "old" {
+ return "Lectio — Extraordinary Form"
+ }
+ return "Lectio — Ordinary Form"
+}
+
+// ICal renders days as an RFC-5545 VCALENDAR, one all-day VEVENT per day.
+func ICal(form string, days []DayView, stamp time.Time) []byte {
+ var lines []string
+ add := func(s string) { lines = append(lines, foldLine(s)) }
+ add("BEGIN:VCALENDAR")
+ add("VERSION:2.0")
+ add("PRODID:-//lectio//calendar//EN")
+ add("CALSCALE:GREGORIAN")
+ add("METHOD:PUBLISH")
+ add("X-WR-CALNAME:" + icalEscape(calName(form)))
+ ds := stamp.UTC().Format("20060102T150405Z")
+ for _, d := range days {
+ date := strings.ReplaceAll(d.Date, "-", "") // YYYYMMDD
+ next, _ := time.Parse("2006-01-02", d.Date)
+ end := next.AddDate(0, 0, 1).Format("20060102")
+ var desc []string
+ desc = append(desc, "Season: "+d.Season+" (week "+strconv.Itoa(d.Week)+")")
+ if d.Observed.Rank != "" {
+ desc = append(desc, "Rank: "+d.Observed.Rank)
+ }
+ desc = append(desc, "Colour: "+d.Colour)
+ for _, r := range d.Readings {
+ desc = append(desc, r.Part+": "+r.Citation)
+ }
+ add("BEGIN:VEVENT")
+ add("UID:" + d.Date + "-" + form + "@lectio") // input-free, stable
+ add("DTSTAMP:" + ds)
+ add("DTSTART;VALUE=DATE:" + date)
+ add("DTEND;VALUE=DATE:" + end)
+ add("SUMMARY:" + icalEscape(d.Observed.Name))
+ // join with real \n, then escape the whole string so \n -> \\n and every
+ // TEXT special is neutralised in one pass.
+ add("DESCRIPTION:" + icalEscape(strings.Join(desc, "\n")))
+ if d.Colour != "" {
+ add("CATEGORIES:" + icalEscape(strings.ToUpper(d.Colour)))
+ }
+ add("END:VEVENT")
+ }
+ add("END:VCALENDAR")
+ return []byte(strings.Join(lines, "\r\n") + "\r\n")
+}
+```
+
+- [ ] **Step 4: Run tests** — `go test ./internal/calfeed/` PASS (all three iCal tests + Task 1's JSON test).
+
+- [ ] **Step 5: gofmt + commit** — `gofmt -w internal/calfeed/`; `git commit -m "feat(calfeed): RFC-5545 iCal renderer with injection-safe escaping"`
+
+---
+
+### Task 3: Shared day builder + reading resolver
+
+**Files:**
+- Create: `internal/calfeed/build.go`
+- Modify: `internal/cli/liturgy.go` (export the reading resolver + name helper, or move to a shared spot)
+- Test: `internal/calfeed/build_test.go`
+
+**Interfaces:**
+- Consumes: `calendar.Compute`, `calendar.Selection`, `[]calendar.Layer`, and the reading resolver.
+- Produces: `func Build(from, to time.Time, uiLang string, sel calendar.Selection, layers []calendar.Layer, readings func(date time.Time, day calendar.LiturgicalDay) []calendar.Reading) []DayView`.
+
+- [ ] **Step 1: Make the reading resolver reusable.** In `internal/cli/liturgy.go`, `dayReadings(sel, layers, date, day)` and `celebrationName(cfg, cel)` are the logic to reuse. Extract the reading resolution into an exported function callable without the CLI — simplest: add `func Readings(sel calendar.Selection, layers []calendar.Layer, date time.Time, day calendar.LiturgicalDay) []calendar.Reading` in `internal/caldata` (it already owns `TemporalReadings`), moving the body of `dayReadings` there, and have `cli.dayReadings` delegate to it. Verify no import cycle (`caldata` imports `calendar` only). Keep `cli`'s behaviour identical (its tests must still pass).
+
+- [ ] **Step 2: Write the failing test** `internal/calfeed/build_test.go`: build a 3-day range for a known EF date window and assert `len(days)==3`, dates are contiguous `YYYY-MM-DD`, weekday strings set, and a day with a known feast has `Observed.Name != ""`. (Use `caldata.Tridentine()` as the layer and `calendar.DefaultSelection()` with `Form="old"`.)
+
+- [ ] **Step 3: Run it, verify it fails.**
+
+- [ ] **Step 4: Implement** `internal/calfeed/build.go`: iterate `from..to` inclusive; for each date call `calendar.Compute(date, sel, layers)`, map to `DayView` (season/week/weekday/colour, `Observed`/`Others` via a `celView` mapper that resolves the name in `uiLang` with English/slug fallback), set `Cycles{day.SundayCycle, day.WeekdayCycle}`, and map the injected `readings(date, day)` to `[]ReadingView`. The name-resolution mirrors `cli.celebrationName`.
+
+- [ ] **Step 5: Run tests** — `go test ./internal/calfeed/ ./internal/cli/ ./internal/caldata/` PASS.
+
+- [ ] **Step 6: gofmt + commit** — `git commit -m "feat(calfeed): shared day builder + reusable reading resolver"`
+
+---
+
+### Task 4: CLI `--format json|ical` + range flags
+
+**Files:**
+- Modify: `internal/cli/cli.go` (flag parsing), `internal/cli/liturgy.go` (or new `internal/cli/feed.go`)
+- Test: `internal/cli/feed_test.go`
+
+**Interfaces:**
+- Consumes: `calfeed.Build`, `calfeed.JSON`, `calfeed.ICal` (Tasks 1-3).
+
+- [ ] **Step 1: Add flags** `--format` (json|ical), `--from`, `--to`, `--year`, `--form` to the CLI. `--format` with no range → single day (the positional DATE, default today). Validate: dates via `time.Parse("2006-01-02")`; `form` ∈ {old,new}; `year` in 1583–9999; `from<=to`; CLI sanity cap of 100 years on the span (fixed error to stderr, exit 2).
+
+- [ ] **Step 2: Write the failing test** `internal/cli/feed_test.go`: run the CLI entry with `--format json --from 2026-01-01 --to 2026-01-03`, capture stdout, `json.Unmarshal` it, assert 3 days + schema. Run with `--format ical --year 2026`, assert output starts `BEGIN:VCALENDAR` and has 365 `BEGIN:VEVENT`. Run with `--format json --from 2026-01-05 --to 2026-01-01` → exit 2 (inverted). Run `--format json --year 1500` → exit 2 (out of domain).
+
+- [ ] **Step 3: Run it, verify it fails.**
+
+- [ ] **Step 4: Implement** the handler: parse/validate → build `Selection` (config + `--form` override) → `caldata.Stack` layers → `calfeed.Build(...)` with the reading resolver → `calfeed.JSON` or `calfeed.ICal(..., time.Now())` → write to stdout.
+
+- [ ] **Step 5: Run tests + manual** — `go test ./internal/cli/`; `lectio --format ical --year 2026 | head`; `lectio 2026-01-06 --format json`.
+
+- [ ] **Step 6: gofmt + commit** — `git commit -m "feat(cli): --format json|ical calendar emitters with range + validation"`
+
+---
+
+### Task 5: Web `/api/calendar.json` + `/calendar.ics` (security-critical)
+
+**Files:**
+- Modify: `internal/web/server.go` (routes), create `internal/web/apifeed.go`
+- Test: `internal/web/apifeed_test.go`
+
+**Interfaces:**
+- Consumes: `calfeed.*` (Tasks 1-4); the server's `config.Config` + layers.
+
+- [ ] **Step 1: Register routes** in `NewServer`'s mux: `GET /api/calendar.json` and `GET /calendar.ics`, each delegating to a handler built with `s.get()` (the current config), matching the existing handler style.
+
+- [ ] **Step 2: Write the failing SECURITY tests** `internal/web/apifeed_test.go` using `httptest`:
+ - `GET /api/calendar.json?date=2026-01-06` → 200, `Content-Type: application/json; charset=utf-8`, `X-Content-Type-Options: nosniff`, body parses, 1 day.
+ - `GET /calendar.ics?year=2026` → 200, `Content-Type: text/calendar; charset=utf-8`, `nosniff`, body has `BEGIN:VCALENDAR`.
+ - `GET /api/calendar.json?date=not-a-date` → 400.
+ - `GET /api/calendar.json?from=2026-01-01&to=2026-01-02&form=bogus` → 400.
+ - `GET /calendar.ics?from=2000-01-01&to=2100-01-01` → 400 (over the 1830-day cap), and assert the body does NOT contain `BEGIN:VEVENT` (rejected before compute).
+ - `GET /api/calendar.json?from=2026-02-01&to=2026-01-01` → 400 (inverted).
+
+- [ ] **Step 3: Run them, verify they fail.**
+
+- [ ] **Step 4: Implement** `internal/web/apifeed.go`:
+ - Parse query params; enforce exactly one of {date, from&to, year}; validate dates/form/year as in the CLI; enforce the **1830-day web cap** BEFORE building; on any violation write a fixed plain-text 400 (do not echo raw input) and return.
+ - Build the day list via `calfeed.Build` with the server config's `Selection()` + layers (server-side only — no `use=`/path from the request).
+ - Set `Content-Type` + `w.Header().Set("X-Content-Type-Options", "nosniff")`, then write `calfeed.JSON` / `calfeed.ICal(..., time.Now())`.
+
+- [ ] **Step 5: Run tests** — `go test ./internal/web/` PASS (all security cases).
+
+- [ ] **Step 6: gofmt + commit** — `git commit -m "feat(web): /api/calendar.json + /calendar.ics endpoints (capped, nosniff, validated)"`
+
+---
+
+## Wrap-up
+
+- [ ] `go test ./... && gofmt -l internal/ cmd/`.
+- [ ] Bump `config.Version` (0.34.0 → 0.35.0).
+- [ ] README: document `lectio --format json|ical` and the two endpoints (+ the range cap).
+- [ ] Update memory `lectio-selfcontained-calendar-epic.md`: calendar API (JSON+iCal) shipped; OF readings now the only remaining sub-project.
+- [ ] Use **superpowers:finishing-a-development-branch** to merge/install.
+- [ ] Manual acceptance: `lectio --format ical --year 2026 > /tmp/lectio.ics` opens in a calendar app; `curl localhost:PORT/calendar.ics?year=2026` returns a valid feed with `nosniff`; over-cap range returns 400.
+
+## Self-Review Notes
+
+- Spec coverage: JSON (T1), iCal + escaping (T2), builder/resolver (T3), CLI (T4), web + security cases (T5). Security §D1-D7 all mapped to tests (escape/injection in T2; range cap + validation + nosniff in T5; input validation in T4/T5).
+- Type consistency: `DayView`/`CelView`/`ReadingView`/`Cycles` defined in T1 and used unchanged in T2-T5; `JSON(form, days)`, `ICal(form, days, stamp)`, `Build(from,to,uiLang,sel,layers,readings)` signatures stable across tasks.
+- Import-cycle watch (T3): the reading resolver moves to `internal/caldata` (imports only `calendar`); `internal/calfeed` imports `calendar` (+ stdlib); `cli`/`web` import `calfeed` + `caldata`. No cycles.
+- Confirm-before-coding: the exact body of `cli.dayReadings` (T3 Step 1) and the CLI flag wiring style (T4 Step 1) must be read from current code, not assumed. The T2 iCal sample has a deliberate UID cleanup note — implement the clean `UID:<date>-<form>@lectio` form.