summaryrefslogtreecommitdiff
path: root/docs/superpowers/plans/2026-07-27-lectio-calendar-api.md
blob: f778ee8dd88b2ee5513b7f0bbf94010e333c349f (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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
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.