Files
2026-05-27 23:29:53 +01:00

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/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"