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

8.6 KiB

add-events-ics-export

Package: @suaveplan/api-plugin-events Status: PROPOSED Type: feature-add (follow-up to shipped add-api-plugin-events) Spec delta: ./specs/api-plugin-events/spec.md

Why

The shipped @suaveplan/api-plugin-events (c59bf9a03) scoped out ICS / iCalendar export at landing time on the grounds that the parity-migration was already large and the export had no upstream code to migrate verbatim. The deferral is listed explicitly in openspec/archive/2026-05-19-add-api-plugin-events/proposal.md under ## Out of scope. Without ICS export, any consumer who wants to surface an "Add to Calendar" button or accept a calendar-app import from one of its events has to hand-roll the RFC 5545 builder. Hand-rolling RFC 5545 correctly is non-trivial — escape rules in §3.3.11, line folding at the 75-octet boundary in §3.1, CRLF line endings, and attendee mapping each introduce easy-to-miss defects.

This change ships ONLY ICS export. The other items still listed in the shipped proposal's ## Out of scope (RRULE recurrence, notification channel binding, calendar sync, waitlist, ticketing, SSE live updates) remain deferred to their own follow-up changes.

What changes

  • ADDED: GET /events/:id/ics route — returns text/calendar; charset=utf-8 with a single-VEVENT VCALENDAR body for the requested event. Status, start, end, summary, description, location, and stable UID are all emitted from the existing event schema fields.
  • ADDED: GET /events/:id/ics?with=rsvps variant — same VCALENDAR body plus one ATTENDEE line per RSVP row. Restricted to the event creator because the RSVP roster is private. Non-creators receive 403.
  • ADDED: Content-Disposition: attachment; filename="event-<id>.ics" header on every success response.
  • ADDED: RFC-5545 builder under src/ics/ics-builder/ — pure functions: buildVCalendar, foldLine (75-octet boundary per §3.1), escapeText (§3.3.11), formatDateUtc (UTC YYYYMMDDTHHMMSSZ), mapStatus (event status integer → CONFIRMED / CANCELLED / TENTATIVE), mapAttendeeStatus (RSVP going/maybe/not_going/invited → PARTSTAT ACCEPTED/TENTATIVE/DECLINED/NEEDS-ACTION).
  • ADDED: ICS-export route module under src/routes/ics-export/ — createIcsExportRoutes({ eventRepository, rsvpRepository, clock, instruments }) returning an OpenAPIHono.
  • ADDED: EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY — Zod boundary failure on the with query parameter surfaces as HTTP 422 carrying this code.
  • ADDED: Two OTel counters on the existing instruments bundle — events.ics.served_total{with} (incremented on every successful response, labelled with "none" or "rsvps") and events.ics.failed_total{reason} (incremented on every error response, labelled with "not_found", "forbidden", "unauthenticated", or "invalid_query").
  • ADDED: Injectable Clock (@suaveplan/types) on the ICS route module so DTSTAMP is deterministic in tests; plugin defaults to Date.now.
  • MODIFIED: EventsRoutesVariables — unchanged surface, the ICS routes reuse the same user slot set by the host auth middleware.
  • MODIFIED: createEventsPlugin — onReady now mounts the ICS route module alongside createEventRoutes under the same basePath.

Source mapping

This change is a genesis-original feature; there is no upstream ICS code in ../suaveplan/packages/server/api-plugin-events/ to migrate. The requirement-to-source mapping is RFC-pinned, not code-pinned.

Requirement Source Notes
GET /events/:id/ics returns text/calendar; charset=utf-8 with a single VEVENT RFC 5545 §3.4 (iCalendar object) + §3.6.1 (VEVENT component) Body is one VCALENDAR with one VEVENT.
Stable UID per event RFC 5545 §3.8.4.7 UID:${eventId}@suaveplan.events form — deterministic from the event id.
DTSTAMP in UTC YYYYMMDDTHHMMSSZ form RFC 5545 §3.8.7.2 + §3.3.5 Sourced from injected Clock so tests are byte-stable.
DTSTART / DTEND in UTC YYYYMMDDTHHMMSSZ form RFC 5545 §3.8.2.4 / §3.8.2.2 + §3.3.5 Events on this plugin are stored in UTC per the existing service contract (event schema timezone defaults "UTC"); VTIMEZONE blocks are explicitly scoped out for v0.1.
SUMMARY set to event title, escaped per §3.3.11 RFC 5545 §3.8.1.12 + §3.3.11 , ; \ newline are backslash-escaped.
DESCRIPTION set to event description if present, escaped per §3.3.11 RFC 5545 §3.8.1.5 + §3.3.11 Same escape rules as SUMMARY.
LOCATION set to event location.name if present, escaped RFC 5545 §3.8.1.7 + §3.3.11 Free-form text. Address / coordinates not included in v0.1.
STATUS mapped from event status integer (0→TENTATIVE, 1→CONFIRMED, 2→CANCELLED) RFC 5545 §3.8.1.11 TENTATIVE / CONFIRMED / CANCELLED are the only VEVENT-valid values.
SEQUENCE:0 always emitted (event schema has no revision counter) RFC 5545 §3.8.7.4 Explicitly fixed at 0 until a revision field is added.
Line folding at 75 octets per §3.1 (continuation lines start with one space) RFC 5545 §3.1 Byte-level fold; long DESCRIPTION lines are folded inside the builder.
CRLF (\r\n) line endings throughout RFC 5545 §3.1 Every output line including the final BEGIN/END terminators.
?with=rsvps adds one ATTENDEE line per RSVP, with CN from user_name, PARTSTAT from the RSVP status, and the user id encoded as mailto:<id>@suaveplan.local when user_email is absent RFC 5545 §3.8.4.1 (ATTENDEE) + §3.3.3 (CAL-ADDRESS) mailto: placeholder satisfies CAL-ADDRESS while preserving the user id; the @suaveplan.local suffix is non-routable.
?with=rsvps requires creator (403 for non-creators) Genesis security convention RSVP roster is private; only the event owner may export it.
Content-Disposition: attachment; filename="event-<id>.ics" RFC 6266 §4 (Content-Disposition) Forces calendar-app download instead of inline render.
Typed EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY for malformed with AGENTS.md §2c New code rather than reusing an unrelated existing code; thrown by ≥1 prod path (Zod boundary handler).
OTel counters events.ics.served_total{with} + events.ics.failed_total{reason} CLAUDE.md "Observability" + genesis-wide policy Real in-memory MeterProvider asserts emission.

Out of scope

The shipped proposal's full ## Out of scope list is preserved unchanged for the items NOT being addressed here:

  • RRULE recurrence / RFC 5545 RRULE expansion — deferred to add-api-plugin-events-recurrence.
  • VTIMEZONE blocks — events are stored UTC; tzid emission lands when @suaveplan/datetime adds the canonical IANA-to-VTIMEZONE mapper.
  • Calendar-feed subscriptions (text/calendar polled at a stable URL with an Etag), push delivery via WebDAV / CalDAV, ticketing payloads (X-WR-* Apple extensions, structured ATTACH).
  • Notification fan-out via @suaveplan/notification-channel — unchanged from the shipped proposal.
  • Rate-limit / audit-log on the ICS endpoint — composed at the host level once api-plugin-rate-limit / api-plugin-audit ship.

Verification approach

  • src/ics/ics-builder/ics-builder.test.ts — byte-level builder tests: fold at 75 octets, escape , ; \ newline, CRLF endings, UTC date format, status mapping, attendee PARTSTAT mapping, RSVP variant body.
  • src/routes/ics-export/ics-export.test.ts — real Hono request: 200 on success, 401 on missing user, 404 on missing event, 403 when a non-creator requests ?with=rsvps, 422 on invalid with value, Content-Type and Content-Disposition header asserts, OTel counter assertions via real in-memory MeterProvider.
  • src/otel/instruments.test.ts — extended to cover the two new counters.
  • src/error-codes/error-codes.test.ts — extended to cover the new ICS_INVALID_QUERY code reachability.
  • src/api-plugin-events.spec-conformance.test.ts — pins the new public symbols createIcsExportRoutes + buildVCalendar.

Migration impact

Additive only on the public surface. Consumers who already mount createEventsPlugin() automatically gain the GET /events/:id/ics endpoint on their existing basePath. No breaking changes to existing routes, schemas, or error codes.

Impact

Affected dependents

Run bunx turbo ls --filter=...@suaveplan/api-plugin-events to enumerate.

  • No @suaveplan/* dependents exist on genesis today (verified by bunx turbo ls --filter=...@suaveplan/api-plugin-events — zero matches). Downstream apps that already mount the plugin gain the new endpoint automatically.