13 KiB
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/icsruns withoutuserset in the Hono context - THEN the response status is
401and the body containscode: "EVENTS_PLUGIN.UNAUTHENTICATED"
Scenario: 200 success returns VCALENDAR with single VEVENT
- WHEN
GET /event:1/icsruns withuserset andeventRepository.findByIdreturns an event - THEN the response status is
200,Content-Typeistext/calendar; charset=utf-8,Content-Dispositionisattachment; filename="event-event:1.ics", and the body matches/^BEGIN:VCALENDAR\r\n[\s\S]+\r\nEND:VCALENDAR\r\n$/containing exactly oneBEGIN:VEVENT/END:VEVENTpair
Scenario: missing event returns 404
- WHEN
GET /missing/icsruns andfindByIdreturnsnull - THEN the response status is
404and the body carriescode: "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", start2026-01-01T10:00:00Z, end2026-01-01T12:00:00Z, status1 - 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: 0and again withstatus: 2 - THEN the first body contains
STATUS:TENTATIVEand the second containsSTATUS:CANCELLED
Scenario: optional fields omit when absent
- WHEN the builder runs with
descriptionundefined andlocationundefined - THEN the body contains no
DESCRIPTION:orLOCATION: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
DESCRIPTIONis 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
buildVCalendarruns against any valid input - THEN the body contains zero
\nbytes 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=rsvpsruns withuser= creator andfindAttendeesreturns 2 RSVP rows - THEN the body contains exactly 2
ATTENDEElines, each carrying aPARTSTAT=parameter and amailto: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=rsvpsruns with a user whereisCreatorreturnsfalse - THEN the response status is
403and the body carriescode: "EVENTS_PLUGIN.FORBIDDEN"
Scenario: invalid with value returns 422
- WHEN
GET /event:1/ics?with=bogusruns - THEN the response status is
422and the body carriescode: "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_CODESand grepssrc/for anew EventsPluginError(EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY, ...)orcode: EVENTS_PLUGIN_CODES.ICS_INVALID_QUERYcall 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
MeterProvideris set,resetEventsInstruments()is called, thenGET /event:1/icsreturns 200 - THEN the next
forceFlushof the in-memory reader containsevents.ics.served_totalwith attributewith=noneand value1
Scenario: 404 path increments failed_total
- WHEN the same setup runs and
GET /missing/icsreturns 404 - THEN the next
forceFlushcontainsevents.ics.failed_totalwith attributereason=not_foundand value1
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
onReadyruns against a fresh registry and app - THEN the app's
routesarray contains at least one entry whosepathends in/:id/ics
Verified by: src/plugin/plugin.test.ts::"onReady mounts the ICS export route"