Files
suaveplan/openspec/archive/2026-05-20-add-events-ics-export/specs/api-plugin-events/spec.md
2026-05-27 23:29:53 +01:00

176 lines
13 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-events (add-events-ics-export)
**Package**: `@suaveplan/api-plugin-events`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at draft time.
## ADDED Requirements
### Requirement: ICS export endpoint
The package SHALL expose a `GET /:id/ics` route mounted by `createEventsPlugin` under the configured `basePath`. The route SHALL return HTTP `200` with `Content-Type: text/calendar; charset=utf-8` and a body containing one RFC 5545 `VCALENDAR` block enclosing a single `VEVENT` for the requested event. The response SHALL carry `Content-Disposition: attachment; filename="event-<id>.ics"` where `<id>` is the URL-safe event id. Authentication SHALL be required: a request without `c.get("user")` SHALL return HTTP `401` with body `{ error: "Authentication required", code: "EVENTS_PLUGIN.UNAUTHENTICATED" }`. A request whose event id does not resolve in `eventRepository.findById` SHALL return HTTP `404` with code `EVENTS_PLUGIN.EVENT_NOT_FOUND`.
#### Scenario: unauthenticated request returns 401
- **WHEN** `GET /event:1/ics` runs without `user` set in the Hono context
- **THEN** the response status is `401` and the body contains `code: "EVENTS_PLUGIN.UNAUTHENTICATED"`
#### Scenario: 200 success returns VCALENDAR with single VEVENT
- **WHEN** `GET /event:1/ics` runs with `user` set and `eventRepository.findById` returns an event
- **THEN** the response status is `200`, `Content-Type` is `text/calendar; charset=utf-8`, `Content-Disposition` is `attachment; filename="event-event:1.ics"`, and the body matches `/^BEGIN:VCALENDAR\r\n[\s\S]+\r\nEND:VCALENDAR\r\n$/` containing exactly one `BEGIN:VEVENT` / `END:VEVENT` pair
#### Scenario: missing event returns 404
- **WHEN** `GET /missing/ics` runs and `findById` returns `null`
- **THEN** the response status is `404` and the body carries `code: "EVENTS_PLUGIN.EVENT_NOT_FOUND"`
**Verified by:** `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 401 without user"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 200 with single VEVENT VCALENDAR"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 404 when event missing"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics sets Content-Disposition attachment header"`
---
### Requirement: VEVENT body content
The single `VEVENT` block SHALL emit the following properties, sourced from the event fields shipped on `EventSchema`: `UID` as `${eventId}@suaveplan.events`; `DTSTAMP` from the injected `Clock` formatted as UTC `YYYYMMDDTHHMMSSZ` per §3.3.5; `DTSTART` from `event.start_time` formatted as UTC `YYYYMMDDTHHMMSSZ`; `DTEND` from `event.end_time` formatted the same way; `SUMMARY` from `event.title` with §3.3.11 escaping applied; `DESCRIPTION` from `event.description` if present, also escaped; `LOCATION` from `event.location.name` if present, also escaped; `STATUS` mapped from the event status integer (`0`→`TENTATIVE`, `1`→`CONFIRMED`, `2`→`CANCELLED`) per §3.8.1.11; and `SEQUENCE:0`. `ORGANIZER` SHALL NOT be emitted in v0.1 because the event schema does not carry a host email — the empty slot is documented in the proposal and remains reserved for a future schema field.
#### Scenario: every required property is present in order
- **WHEN** the builder runs against an event with title `"T"`, description `"d"`, location name `"L"`, start `2026-01-01T10:00:00Z`, end `2026-01-01T12:00:00Z`, status `1`
- **THEN** the body contains exactly one of each line in order: `BEGIN:VEVENT`, `UID:event:1@suaveplan.events`, `DTSTAMP:...Z`, `DTSTART:20260101T100000Z`, `DTEND:20260101T120000Z`, `SUMMARY:T`, `DESCRIPTION:d`, `LOCATION:L`, `STATUS:CONFIRMED`, `SEQUENCE:0`, `END:VEVENT`
#### Scenario: status maps draft → TENTATIVE and cancelled → CANCELLED
- **WHEN** the builder runs with `status: 0` and again with `status: 2`
- **THEN** the first body contains `STATUS:TENTATIVE` and the second contains `STATUS:CANCELLED`
#### Scenario: optional fields omit when absent
- **WHEN** the builder runs with `description` undefined and `location` undefined
- **THEN** the body contains no `DESCRIPTION:` or `LOCATION:` line
**Verified by:** `src/ics/ics-builder/ics-builder.test.ts::"emits every required VEVENT property in canonical order"` AND `src/ics/ics-builder/ics-builder.test.ts::"maps status integer to RFC 5545 STATUS value"` AND `src/ics/ics-builder/ics-builder.test.ts::"omits DESCRIPTION and LOCATION when absent"`
---
### Requirement: Text escape rules per §3.3.11
Every value written into `SUMMARY`, `DESCRIPTION`, or `LOCATION` SHALL be escaped per RFC 5545 §3.3.11: backslash → `\\`, semicolon → `\;`, comma → `\,`, newline (`\n` and `\r\n`) → `\n` (the literal two characters `\` + `n`). Carriage returns inside text SHALL be normalised away before escaping. Escaping SHALL happen before line folding.
#### Scenario: comma semicolon backslash newline are escaped
- **WHEN** `escapeText("a,b;c\\d\ne")` runs
- **THEN** the result is the exact string `a\,b\;c\\d\ne`
#### Scenario: CRLF in source becomes a single escaped \n
- **WHEN** `escapeText("a\r\nb")` runs
- **THEN** the result is the exact string `a\nb`
**Verified by:** `src/ics/ics-builder/ics-builder.test.ts::"escapeText escapes comma semicolon backslash and newline"` AND `src/ics/ics-builder/ics-builder.test.ts::"escapeText normalises CRLF to a single \\n"`
---
### Requirement: Line folding per §3.1
Every emitted content line longer than 75 octets SHALL be folded: the line is split into 75-octet segments and each subsequent segment is prefixed with a single SPACE on a new CRLF-terminated line, exactly as specified in RFC 5545 §3.1. Folding SHALL operate on the post-escape byte stream so multi-byte UTF-8 sequences are not split mid-codepoint when avoidable (the implementation MAY fold at the nearest safe byte boundary ≤75 octets to preserve UTF-8). Every output line including the final `END:VCALENDAR` SHALL terminate with CRLF (`\r\n`).
#### Scenario: a 200-character DESCRIPTION folds into three CRLF-separated segments
- **WHEN** the builder runs against an event whose escaped `DESCRIPTION` is 200 ASCII octets long
- **THEN** the body contains `DESCRIPTION:` followed by the first 63 octets on the first physical line (75 = `DESCRIPTION:` 12 octets + 63 payload), then `\r\n` (CRLF + space) before each subsequent 74-octet payload segment, and every physical line ends in `\r\n`
#### Scenario: every output line ends in CRLF
- **WHEN** `buildVCalendar` runs against any valid input
- **THEN** the body contains zero `\n` bytes that are not preceded by `\r`
**Verified by:** `src/ics/ics-builder/ics-builder.test.ts::"folds a 200-octet DESCRIPTION into 75-octet continuation segments"` AND `src/ics/ics-builder/ics-builder.test.ts::"every output line ends in CRLF"`
---
### Requirement: RSVP attendee export (?with=rsvps)
When the request includes `?with=rsvps`, the response body SHALL contain one `ATTENDEE` line inside the `VEVENT` per row returned by `rsvpRepository.findAttendees(eventId)`. Each `ATTENDEE` line SHALL carry the `CN` parameter from the attendee's `user_name` if present (escaped per §3.3.11), a `PARTSTAT` parameter mapped from the RSVP status (`going`→`ACCEPTED`, `maybe`→`TENTATIVE`, `not_going`→`DECLINED`, `invited`→`NEEDS-ACTION`), and the CAL-ADDRESS value as `mailto:<user_email>` when `user_email` is present or `mailto:<user_id>@suaveplan.local` as a non-routable fallback when it is not. The `?with=rsvps` variant SHALL require the requester to be the event creator: a non-creator SHALL receive HTTP `403` with code `EVENTS_PLUGIN.FORBIDDEN`. The plain `GET /:id/ics` (no `with` query) SHALL NOT emit any `ATTENDEE` lines and SHALL NOT require creator privileges.
#### Scenario: with=rsvps emits one ATTENDEE per RSVP
- **WHEN** `GET /event:1/ics?with=rsvps` runs with `user` = creator and `findAttendees` returns 2 RSVP rows
- **THEN** the body contains exactly 2 `ATTENDEE` lines, each carrying a `PARTSTAT=` parameter and a `mailto:` value
#### Scenario: with=rsvps maps PARTSTAT values
- **WHEN** the builder runs with attendees of status `going`, `maybe`, `not_going`, `invited`
- **THEN** the corresponding ATTENDEE lines carry `PARTSTAT=ACCEPTED`, `PARTSTAT=TENTATIVE`, `PARTSTAT=DECLINED`, `PARTSTAT=NEEDS-ACTION`
#### Scenario: with=rsvps by non-creator returns 403
- **WHEN** `GET /event:1/ics?with=rsvps` runs with a user where `isCreator` returns `false`
- **THEN** the response status is `403` and the body carries `code: "EVENTS_PLUGIN.FORBIDDEN"`
#### Scenario: invalid with value returns 422
- **WHEN** `GET /event:1/ics?with=bogus` runs
- **THEN** the response status is `422` and the body carries `code: "EVENTS_PLUGIN.ICS_INVALID_QUERY"`
**Verified by:** `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics?with=rsvps emits one ATTENDEE per RSVP"` AND `src/ics/ics-builder/ics-builder.test.ts::"maps RSVP status to ATTENDEE PARTSTAT"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics?with=rsvps returns 403 for non-creator"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 422 for invalid with value"`
---
### Requirement: New error code
The package SHALL extend `EVENTS_PLUGIN_CODES` with `ICS_INVALID_QUERY` (registry size 12 → 13). The code SHALL be raised when the Zod schema for the ICS route query rejects an unknown `with` value, surfaced to the client as HTTP `422`. The code SHALL be thrown by at least one production path under `src/routes/ics-export/`.
#### Scenario: ICS_INVALID_QUERY is reachable
- **WHEN** the error-codes conformance test reflects over `EVENTS_PLUGIN_CODES` and greps `src/` for a `new EventsPluginError(EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY, ...)` or `code: EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY` call site
- **THEN** at least one match is found
**Verified by:** `src/error-codes/error-codes.test.ts::"every EVENTS_PLUGIN_CODES value is thrown by src/"`
---
### Requirement: OTel counters
The package SHALL extend `EventsInstruments` with two new counters created against `metrics.getMeter("@suaveplan/api-plugin-events")`: `icsServedTotal` (instrument name `events.ics.served_total`, labelled with `with` taking values `"none"` or `"rsvps"`) and `icsFailedTotal` (instrument name `events.ics.failed_total`, labelled with `reason` taking values `"not_found"`, `"forbidden"`, `"unauthenticated"`, or `"invalid_query"`). Both counters SHALL be lazily built, cached, and cleared by `resetEventsInstruments()`. The ICS route SHALL increment `icsServedTotal` on every successful response and `icsFailedTotal` on every error response.
#### Scenario: success path increments served_total
- **WHEN** a freshly-installed in-memory `MeterProvider` is set, `resetEventsInstruments()` is called, then `GET /event:1/ics` returns 200
- **THEN** the next `forceFlush` of the in-memory reader contains `events.ics.served_total` with attribute `with=none` and value `1`
#### Scenario: 404 path increments failed_total
- **WHEN** the same setup runs and `GET /missing/ics` returns 404
- **THEN** the next `forceFlush` contains `events.ics.failed_total` with attribute `reason=not_found` and value `1`
**Verified by:** `src/otel/instruments.test.ts::"exposes ics served and failed counters"` AND `src/routes/ics-export/ics-export.test.ts::"increments events.ics.served_total on success"` AND `src/routes/ics-export/ics-export.test.ts::"increments events.ics.failed_total on 404"`
---
### Requirement: Public surface (subpath exports + named re-exports)
The package SHALL add two new subpath exports in `package.json`: `./ics/ics-builder/ics-builder` and `./routes/ics-export/ics-export`. The root barrel SHALL add explicit named re-exports of `createIcsExportRoutes`, `buildVCalendar`, `BuildVCalendarInput`, `IcsAttendee`, `IcsExportRoutesConfig`, and `IcsEventStatus` (rule 10 — no `export *` outside generated forwarders).
#### Scenario: new symbols resolve from the package root
- **WHEN** the conformance test imports the package root and asserts the new symbols resolve to non-`undefined`
- **THEN** every expected symbol matches
**Verified by:** `src/api-plugin-events.spec-conformance.test.ts::"re-exports ICS surface (builder + routes)"`
---
### Requirement: Plugin wires ICS routes alongside CRUD routes
`createEventsPlugin`'s `onReady` SHALL mount `createIcsExportRoutes(...)` on the same `OpenAPIHono` instance under the same `basePath` as `createEventRoutes(...)`. Both route modules SHALL share the host-supplied `user` context variable. The plugin SHALL pass `Date.now` as the default `Clock` to the ICS routes; tests MAY override via `createIcsExportRoutes` directly.
#### Scenario: plugin onReady mounts an ICS route
- **WHEN** `onReady` runs against a fresh registry and app
- **THEN** the app's `routes` array contains at least one entry whose `path` ends in `/:id/ics`
**Verified by:** `src/plugin/plugin.test.ts::"onReady mounts the ICS export route"`