From 5ab5ff23417a80eb1b878329fd57828158bd4a31 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 27 Jul 2026 21:22:17 +0200 Subject: docs: implementation plan for calendar API (JSON + iCal) 5 TDD tasks: calfeed JSON renderer, RFC-5545 iCal renderer w/ injection-safe escaping (security test first), shared day builder + reusable reading resolver, CLI --format json|ical + range flags, web endpoints (capped/nosniff/validated). Security controls each mapped to a test. Code grounded in real calendar types + Go 1.22 web mux. --- .../plans/2026-07-27-lectio-calendar-api.md | 409 +++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-lectio-calendar-api.md (limited to 'docs/superpowers/plans') 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:-
@lectio` form. -- cgit v1.3