# 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.