# Capability Spec Delta: api-plugin-events (add-api-plugin-events) **Package**: `@suaveplan/api-plugin-events` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: Plugin factory and lifecycle The package SHALL export `createEventsPlugin(options?: EventsPluginConfigInput)` returning a `PluginDescriptor` that mounts event-management routes against `@suaveplan/api`. The factory SHALL Zod-validate input via `EventsPluginOptionsSchema` at the boundary and throw `EventsPluginError(INVALID_CONFIG)` carrying the `ZodError` as `cause` on invalid input. #### Scenario: valid options produce a descriptor - **WHEN** `createEventsPlugin({ basePath: "/v1/events" })` is called - **THEN** the returned descriptor has `name === "events"`, `dependencies: ["database"]`, and `defaults.basePath === "/v1/events"` #### Scenario: invalid options fail fast - **WHEN** `createEventsPlugin({ maxInvitesPerRequest: -1 })` is called - **THEN** the call throws `EventsPluginError` with `code === EVENTS_PLUGIN_CODES.INVALID_CONFIG` and `cause instanceof ZodError` #### Scenario: onInit registers repositories - **WHEN** the descriptor's `onInit(ctx)` runs with a real `db` service in the registry - **THEN** `ctx.getService("eventRepository")` and `ctx.getService("rsvpRepository")` resolve to non-null repository instances #### Scenario: onInit fails without database service - **WHEN** `onInit(ctx)` runs with no `db` service registered - **THEN** it throws `EventsPluginError` with `code === EVENTS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE` #### Scenario: onReady mounts routes under basePath - **WHEN** `onReady(ctx)` runs after `onInit` - **THEN** `ctx.getService("app").routes` contains entries whose `path` begins with the configured `basePath` #### Scenario: onReady refuses to double-mount - **WHEN** `onReady(ctx)` is invoked twice against the same app instance - **THEN** the second call throws `EventsPluginError` with `code === EVENTS_PLUGIN_CODES.ROUTE_PREFIX_COLLISION` #### Scenario: onClose clears repositories - **WHEN** `onClose()` is invoked after a successful boot - **THEN** subsequent lifecycle methods on the descriptor's `runtime` see cleared `eventRepository` / `rsvpRepository` state and the registry's slots are no longer accessed by the plugin **Verified by:** `src/plugin/plugin.test.ts::"createEventsPlugin returns descriptor with defaults"` AND `src/plugin/plugin.test.ts::"createEventsPlugin throws INVALID_CONFIG for bad options"` AND `src/plugin/plugin.test.ts::"onInit registers eventRepository and rsvpRepository"` AND `src/plugin/plugin.test.ts::"onInit throws SERVICE_NOT_AVAILABLE without db"` AND `src/plugin/plugin.test.ts::"onReady mounts routes under basePath"` AND `src/plugin/plugin.test.ts::"onReady throws ROUTE_PREFIX_COLLISION on double-mount"` AND `src/plugin/plugin.test.ts::"onClose clears runtime state"` --- ### Requirement: Configuration schema The package SHALL expose `EventsPluginOptionsSchema` (Zod) producing `EventsPluginConfig` with three fields: `basePath: string` (default `/events`), `enableNotifications: boolean` (default `false`), `maxInvitesPerRequest: integer between 1 and 100` (default `50`). #### Scenario: defaults apply when input is empty - **WHEN** `EventsPluginOptionsSchema.parse({})` is called - **THEN** the returned object equals `{ basePath: "/events", enableNotifications: false, maxInvitesPerRequest: 50 }` #### Scenario: out-of-range invite cap rejected - **WHEN** `EventsPluginOptionsSchema.safeParse({ maxInvitesPerRequest: 0 })` is called - **THEN** `success === false` and `error.issues[0].path` includes `maxInvitesPerRequest` **Verified by:** `src/schemas/options.test.ts::"parses empty input to defaults"` AND `src/schemas/options.test.ts::"rejects out-of-range maxInvitesPerRequest"` --- ### Requirement: EventRepository CRUD The package SHALL expose `EventRepository` implementing `IEventRepository` with `findAll(query?)`, `findById(id)`, `create(userId, data)`, `update(id, data)`, `delete(id)`, `isCreator(eventId, userId)`, and `updateAttendeeCount(eventId, status, increment)`. Every method SHALL accept user IDs with or without the `user:` prefix and event IDs with or without the `event:` prefix, normalising both forms internally. #### Scenario: findAll applies status / upcoming / past filters - **WHEN** `findAll({ status: 1, upcoming: true, limit: 10, offset: 5 })` is called - **THEN** the underlying adapter receives a SELECT with `WHERE status = $p0 AND end_time > time::now()` and positional params `[1]` plus pagination clauses #### Scenario: findById returns null when missing - **WHEN** `findById("nonexistent")` runs and the adapter returns `[]` - **THEN** the method returns `null` #### Scenario: create raises FAILURE on empty response - **WHEN** `create("user:1", data)` runs and the adapter returns `[]` - **THEN** the method throws `EventsPluginError` with `code === EVENTS_PLUGIN_CODES.EVENT_CREATE_FAILED` #### Scenario: update emits only provided fields - **WHEN** `update("1", { title: "X" })` is called - **THEN** the SET clause includes `title = $p0` and does not include `description`, `start_time`, etc. #### Scenario: isCreator handles RecordId / object / string shapes - **WHEN** the adapter returns `{ creator: { tb: "user", id: "1" } }` and `isCreator("event:1", "user:1")` is called - **THEN** the method returns `true` #### Scenario: updateAttendeeCount no-ops for not_going - **WHEN** `updateAttendeeCount("event:1", "not_going", 1)` is called - **THEN** no adapter query runs **Verified by:** `src/repositories/event-repository/event-repository.test.ts::"findAll applies status and upcoming filters"` AND `src/repositories/event-repository/event-repository.test.ts::"findById returns null when not found"` AND `src/repositories/event-repository/event-repository.test.ts::"create raises EVENT_CREATE_FAILED on empty response"` AND `src/repositories/event-repository/event-repository.test.ts::"update only emits provided fields"` AND `src/repositories/event-repository/event-repository.test.ts::"isCreator handles RecordId object"` AND `src/repositories/event-repository/event-repository.test.ts::"updateAttendeeCount no-ops for not_going"` --- ### Requirement: RsvpRepository surface The package SHALL expose `RsvpRepository` implementing `IRsvpRepository` with `findByEventAndUser`, `findAttendees`, `upsert`, `delete`, `createInvitations`, and `countByStatus`. The repository SHALL idempotently skip existing invitations when `createInvitations` is called with a user that already has an RSVP row. #### Scenario: upsert creates when no row exists - **WHEN** `upsert("event:1", "user:1", { status: "going" })` runs against an empty table - **THEN** the adapter receives a CREATE statement and the returned RSVP has `status === "going"` #### Scenario: upsert updates when a row exists - **WHEN** `upsert("event:1", "user:1", { status: "maybe" })` runs and a row already exists for the same (event, user) - **THEN** the adapter receives an UPDATE statement instead of CREATE #### Scenario: delete returns the prior status - **WHEN** `delete("event:1", "user:1")` runs and the prior row had `status: "going"` - **THEN** the method returns `"going"` and the row is deleted #### Scenario: createInvitations skips existing rows - **WHEN** `createInvitations("event:1", ["user:1", "user:2"])` runs and `user:1` already has an RSVP - **THEN** the returned array has length 1 (only `user:2`) #### Scenario: countByStatus returns 0 on empty - **WHEN** `countByStatus("event:1", "going")` runs and the adapter returns `[]` - **THEN** the method returns `0` **Verified by:** `src/repositories/rsvp-repository/rsvp-repository.test.ts::"upsert creates new row when none exists"` AND `src/repositories/rsvp-repository/rsvp-repository.test.ts::"upsert updates when row exists"` AND `src/repositories/rsvp-repository/rsvp-repository.test.ts::"delete returns prior status"` AND `src/repositories/rsvp-repository/rsvp-repository.test.ts::"createInvitations skips existing rows"` AND `src/repositories/rsvp-repository/rsvp-repository.test.ts::"countByStatus returns 0 on empty result"` --- ### Requirement: Route surface (9 endpoints) The package SHALL expose `createEventRoutes({ eventRepository, rsvpRepository, emitEvent? })` returning an `OpenAPIHono` instance with these routes (relative to the mount prefix): `GET /`, `GET /{id}`, `POST /`, `PUT /{id}`, `DELETE /{id}`, `POST /{id}/rsvp`, `DELETE /{id}/rsvp`, `GET /{id}/my-rsvp`, `GET /{id}/attendees`, `POST /{id}/invite`. Each route SHALL be wrapped with `@hono/zod-openapi` `createRoute`, validating request bodies / params / queries through the package's Zod schemas. Every route SHALL require an authenticated user (`c.get("user")`) and return `401` with `EVENTS_PLUGIN_CODES.UNAUTHENTICATED` when absent. #### Scenario: unauthenticated request returns 401 - **WHEN** a request reaches `GET /` without `user` set in the Hono context - **THEN** the response status is `401` and the body is `{ error: "Authentication required" }` #### Scenario: list returns paginated events - **WHEN** `GET /` runs with `user` set and `eventRepository.findAll` returns 2 events - **THEN** the response is `200` and the body is `{ data: [event1, event2] }` #### Scenario: get returns 404 when event missing - **WHEN** `GET /missing-id` runs with `user` set and `findById` returns `null` - **THEN** the response is `404` with body `{ error: "Event not found" }` #### Scenario: create emits event and returns 201 - **WHEN** `POST /` runs with valid body and `user.id = "user:1"` - **THEN** the response is `201`, the body is the created event, and `emitEvent` is called with `{ type: "event:created", payload: { eventId, creatorId: "user:1" } }` #### Scenario: update requires creator - **WHEN** `PUT /{id}` runs with a user who is not the event creator - **THEN** the response is `403` with body `{ error: "Only event creator can update this event" }` #### Scenario: delete by non-creator forbidden - **WHEN** `DELETE /{id}` runs with a non-creator user - **THEN** the response is `403` #### Scenario: rsvp updates attendee counts - **WHEN** `POST /{id}/rsvp` runs with `status: "going"` and no prior RSVP - **THEN** `eventRepository.updateAttendeeCount` is called with `(id, "going", 1)` and `emitEvent` fires `rsvp:created` #### Scenario: cancel-rsvp returns 404 when no prior RSVP - **WHEN** `DELETE /{id}/rsvp` runs and the user has no RSVP row - **THEN** the response is `404` #### Scenario: attendees returns paginated list - **WHEN** `GET /{id}/attendees` runs - **THEN** the response is `200` with body `{ data: [...], total: }` #### Scenario: invite requires creator - **WHEN** `POST /{id}/invite` runs with a non-creator - **THEN** the response is `403` #### Scenario: invite enforces maxInvitesPerRequest - **WHEN** `POST /{id}/invite` is called with a `user_ids` array longer than `maxInvitesPerRequest` - **THEN** validation fails at the Zod boundary with a `400` **Verified by:** `src/routes/events-routes/events-routes.test.ts::"GET / returns 401 without user"` AND `src/routes/events-routes/events-routes.test.ts::"GET / returns paginated events"` AND `src/routes/events-routes/events-routes.test.ts::"GET /{id} returns 404 when missing"` AND `src/routes/events-routes/events-routes.test.ts::"POST / emits event:created and returns 201"` AND `src/routes/events-routes/events-routes.test.ts::"PUT /{id} returns 403 for non-creator"` AND `src/routes/events-routes/events-routes.test.ts::"DELETE /{id} returns 403 for non-creator"` AND `src/routes/events-routes/events-routes.test.ts::"POST /{id}/rsvp updates attendee count"` AND `src/routes/events-routes/events-routes.test.ts::"DELETE /{id}/rsvp returns 404 when none"` AND `src/routes/events-routes/events-routes.test.ts::"GET /{id}/attendees returns list"` AND `src/routes/events-routes/events-routes.test.ts::"POST /{id}/invite returns 403 for non-creator"` AND `src/schemas/rsvp-schemas.test.ts::"InviteUsersSchema enforces user_ids length"` --- ### Requirement: Error code registry The package SHALL expose `EVENTS_PLUGIN_CODES = defineCodes("EVENTS_PLUGIN", [...])` covering 12 stable codes: `INVALID_CONFIG`, `SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION`, `EVENT_CREATE_FAILED`, `EVENT_UPDATE_FAILED`, `RSVP_CREATE_FAILED`, `RSVP_UPDATE_FAILED`, `RSVP_NOT_FOUND`, `EVENT_NOT_FOUND`, `UNAUTHENTICATED`, `FORBIDDEN`, `INVITE_LIMIT_EXCEEDED`. The namespace `"EVENTS_PLUGIN"` (not `"EVENTS"`) prevents collision with `@suaveplan/events`. Every code SHALL be thrown by at least one production path. #### Scenario: every code is reachable - **WHEN** the conformance test reflects over `EVENTS_PLUGIN_CODES` and grep-asserts each value appears in a `new EventsPluginError(, ...)` call site under `src/` - **THEN** every code resolves to ≥1 match **Verified by:** `src/error-codes/error-codes.test.ts::"every EVENTS_PLUGIN_CODES value is thrown by src/"` --- ### Requirement: OTel instruments The package SHALL emit 5 OpenTelemetry instruments via `metrics.getMeter("@suaveplan/api-plugin-events")`: `eventsCreatedTotal` (Counter), `eventsUpdatedTotal` (Counter), `eventsDeletedTotal` (Counter), `rsvpsWrittenTotal` (Counter, labelled by `status`), `invitationsSentTotal` (Counter). Instruments SHALL be lazily built and cached; `resetEventsInstruments()` SHALL clear the cache for test re-binding. #### Scenario: instruments emit on real production paths - **WHEN** a freshly-installed in-memory `MeterProvider` is set, `resetEventsInstruments()` is called, then `POST /` succeeds - **THEN** the next `forceFlush` of the in-memory reader contains `events.created_total` with value `1` **Verified by:** `src/otel/instruments.test.ts::"getEventsInstruments returns the same bundle on repeated calls"` AND `src/otel/instruments.test.ts::"emits events.created_total when a create succeeds"` AND `src/otel/instruments.test.ts::"resetEventsInstruments rebinds against a fresh provider"` --- ### Requirement: Service registry augmentation The package SHALL augment `@suaveplan/api-plugin/service-registry` `ServiceRegistry` with `eventRepository: IEventRepository` and `rsvpRepository: IRsvpRepository` slots so downstream plugins resolve them via `ctx.getService("eventRepository")` with full typing. #### Scenario: registered services resolve with the augmented type - **WHEN** a downstream `PluginContext` calls `ctx.getService("eventRepository")` after the events plugin's `onInit` ran - **THEN** the value is an `IEventRepository` (compile-time + runtime) **Verified by:** `src/api-plugin-events.spec-conformance.test.ts::"ServiceRegistry exposes eventRepository and rsvpRepository slots"` --- ### Requirement: Public surface (subpath exports) The package SHALL expose the following subpath exports in `package.json`: `.`, `./plugin/plugin`, `./schemas/options`, `./schemas/event-schemas`, `./schemas/rsvp-schemas`, `./error-codes/error-codes`, `./otel/instruments`, `./repositories/event-repository/event-repository`, `./repositories/rsvp-repository/rsvp-repository`, `./routes/events-routes/events-routes`, `./types/types`. The root barrel SHALL re-export every public symbol via explicit named exports (rule 10 — no `export *` outside generated forwarders). #### Scenario: every documented symbol is re-exported by the root - **WHEN** the spec-conformance test imports the package root and reflects over expected symbols - **THEN** every expected symbol resolves to a non-`undefined` value **Verified by:** `src/api-plugin-events.spec-conformance.test.ts::"re-exports every documented symbol"` --- ### Requirement: SurrealDB migration payload The package SHALL ship `surql/tables/event.surql` defining the SCHEMAFULL tables `event` and `event_rsvp` plus the supporting indexes (`event_start_idx`, `event_rsvp_unique_idx`). The migration SHALL parse and execute cleanly against a real SurrealDB v3 instance with no cross-plugin function references — the upstream cross-plugin permission rules and helper functions (`fn::bitwise::*`, `fn::event::*`, `fn::group::*`, `fn::notification::*`) and the auxiliary `event_update` table are intentionally scoped out (see proposal `## Out of scope`) because they depend on plugins that do not yet exist on genesis. Permissions are enforced at the route / repository layer, not the database layer. #### Scenario: surql loads cleanly - **WHEN** the full-boot integration test executes the migration against a real SurrealDB v3 container - **THEN** subsequent `CREATE event` and `CREATE event_rsvp` statements succeed and round-trip the inserted fields **Verified by:** `tests/integration/full-boot.test.ts::"boots, creates event, records rsvp, increments count, cleans up"` --- ### Requirement: Integration boot The package SHALL boot end-to-end against a real SurrealDB v3 instance: `createEventsPlugin` → `onInit` → `onReady` → CRUD a real event → RSVP → delete. The integration test SHALL skip cleanly when `isSurrealDBAvailable()` is `false`. #### Scenario: full lifecycle against real Surreal - **WHEN** a SurrealDB v3 testcontainer is running and the integration test invokes the full boot - **THEN** an `event` is created, an `rsvp` is recorded, the attendee count rises by 1, the rsvp is cancelled, and the event is deleted — every step round-trips through `db.query` without throwing **Verified by:** `tests/integration/full-boot.test.ts::"boots, creates event, records rsvp, increments count, cleans up"` ### 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-.ics"` where `` 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:` when `user_email` is present or `mailto:@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"`