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

30 KiB

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<EventsPluginConfig> 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: <count> }

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(<code>, ...) 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-<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"