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/icsroute — returnstext/calendar; charset=utf-8with 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=rsvpsvariant — same VCALENDAR body plus oneATTENDEEline per RSVP row. Restricted to the event creator because the RSVP roster is private. Non-creators receive403. - 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(UTCYYYYMMDDTHHMMSSZ),mapStatus(event status integer → CONFIRMED / CANCELLED / TENTATIVE),mapAttendeeStatus(RSVPgoing/maybe/not_going/invited→ PARTSTATACCEPTED/TENTATIVE/DECLINED/NEEDS-ACTION). - ADDED: ICS-export route module under
src/routes/ics-export/—createIcsExportRoutes({ eventRepository, rsvpRepository, clock, instruments })returning anOpenAPIHono. - ADDED:
EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY— Zod boundary failure on thewithquery parameter surfaces as HTTP422carrying 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") andevents.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 soDTSTAMPis deterministic in tests; plugin defaults toDate.now. - MODIFIED:
EventsRoutesVariables— unchanged surface, the ICS routes reuse the sameuserslot set by the host auth middleware. - MODIFIED:
createEventsPlugin—onReadynow mounts the ICS route module alongsidecreateEventRoutesunder the samebasePath.
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
RRULEexpansion — deferred toadd-api-plugin-events-recurrence. - VTIMEZONE blocks — events are stored UTC; tzid emission lands when
@suaveplan/datetimeadds the canonical IANA-to-VTIMEZONE mapper. - Calendar-feed subscriptions (
text/calendarpolled at a stable URL with anEtag), push delivery via WebDAV / CalDAV, ticketing payloads (X-WR-*Apple extensions, structuredATTACH). - 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-auditship.
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 invalidwithvalue,Content-TypeandContent-Dispositionheader asserts, OTel counter assertions via real in-memoryMeterProvider.src/otel/instruments.test.ts— extended to cover the two new counters.src/error-codes/error-codes.test.ts— extended to cover the newICS_INVALID_QUERYcode reachability.src/api-plugin-events.spec-conformance.test.ts— pins the new public symbolscreateIcsExportRoutes+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 bybunx turbo ls --filter=...@suaveplan/api-plugin-events— zero matches). Downstream apps that already mount the plugin gain the new endpoint automatically.