# 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-.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:@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-.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.