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"], anddefaults.basePath === "/v1/events"
Scenario: invalid options fail fast
- WHEN
createEventsPlugin({ maxInvitesPerRequest: -1 })is called - THEN the call throws
EventsPluginErrorwithcode === EVENTS_PLUGIN_CODES.INVALID_CONFIGandcause instanceof ZodError
Scenario: onInit registers repositories
- WHEN the descriptor's
onInit(ctx)runs with a realdbservice in the registry - THEN
ctx.getService("eventRepository")andctx.getService("rsvpRepository")resolve to non-null repository instances
Scenario: onInit fails without database service
- WHEN
onInit(ctx)runs with nodbservice registered - THEN it throws
EventsPluginErrorwithcode === EVENTS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE
Scenario: onReady mounts routes under basePath
- WHEN
onReady(ctx)runs afteronInit - THEN
ctx.getService("app").routescontains entries whosepathbegins with the configuredbasePath
Scenario: onReady refuses to double-mount
- WHEN
onReady(ctx)is invoked twice against the same app instance - THEN the second call throws
EventsPluginErrorwithcode === 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
runtimesee clearedeventRepository/rsvpRepositorystate 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 === falseanderror.issues[0].pathincludesmaxInvitesPerRequest
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
EventsPluginErrorwithcode === 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 = $p0and does not includedescription,start_time, etc.
Scenario: isCreator handles RecordId / object / string shapes
- WHEN the adapter returns
{ creator: { tb: "user", id: "1" } }andisCreator("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 hadstatus: "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 anduser:1already 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 /withoutuserset in the Hono context - THEN the response status is
401and the body is{ error: "Authentication required" }
Scenario: list returns paginated events
- WHEN
GET /runs withuserset andeventRepository.findAllreturns 2 events - THEN the response is
200and the body is{ data: [event1, event2] }
Scenario: get returns 404 when event missing
- WHEN
GET /missing-idruns withuserset andfindByIdreturnsnull - THEN the response is
404with body{ error: "Event not found" }
Scenario: create emits event and returns 201
- WHEN
POST /runs with valid body anduser.id = "user:1" - THEN the response is
201, the body is the created event, andemitEventis 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
403with 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}/rsvpruns withstatus: "going"and no prior RSVP - THEN
eventRepository.updateAttendeeCountis called with(id, "going", 1)andemitEventfiresrsvp:created
Scenario: cancel-rsvp returns 404 when no prior RSVP
- WHEN
DELETE /{id}/rsvpruns and the user has no RSVP row - THEN the response is
404
Scenario: attendees returns paginated list
- WHEN
GET /{id}/attendeesruns - THEN the response is
200with body{ data: [...], total: <count> }
Scenario: invite requires creator
- WHEN
POST /{id}/inviteruns with a non-creator - THEN the response is
403
Scenario: invite enforces maxInvitesPerRequest
- WHEN
POST /{id}/inviteis called with auser_idsarray longer thanmaxInvitesPerRequest - 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_CODESand grep-asserts each value appears in anew EventsPluginError(<code>, ...)call site undersrc/ - 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
MeterProvideris set,resetEventsInstruments()is called, thenPOST /succeeds - THEN the next
forceFlushof the in-memory reader containsevents.created_totalwith value1
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
PluginContextcallsctx.getService("eventRepository")after the events plugin'sonInitran - 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-
undefinedvalue
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 eventandCREATE event_rsvpstatements 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
eventis created, anrsvpis recorded, the attendee count rises by 1, the rsvp is cancelled, and the event is deleted — every step round-trips throughdb.querywithout 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/icsruns withoutuserset in the Hono context - THEN the response status is
401and the body containscode: "EVENTS_PLUGIN.UNAUTHENTICATED"
Scenario: 200 success returns VCALENDAR with single VEVENT
- WHEN
GET /event:1/icsruns withuserset andeventRepository.findByIdreturns an event - THEN the response status is
200,Content-Typeistext/calendar; charset=utf-8,Content-Dispositionisattachment; filename="event-event:1.ics", and the body matches/^BEGIN:VCALENDAR\r\n[\s\S]+\r\nEND:VCALENDAR\r\n$/containing exactly oneBEGIN:VEVENT/END:VEVENTpair
Scenario: missing event returns 404
- WHEN
GET /missing/icsruns andfindByIdreturnsnull - THEN the response status is
404and the body carriescode: "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", start2026-01-01T10:00:00Z, end2026-01-01T12:00:00Z, status1 - 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: 0and again withstatus: 2 - THEN the first body contains
STATUS:TENTATIVEand the second containsSTATUS:CANCELLED
Scenario: optional fields omit when absent
- WHEN the builder runs with
descriptionundefined andlocationundefined - THEN the body contains no
DESCRIPTION:orLOCATION: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
DESCRIPTIONis 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
buildVCalendarruns against any valid input - THEN the body contains zero
\nbytes 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=rsvpsruns withuser= creator andfindAttendeesreturns 2 RSVP rows - THEN the body contains exactly 2
ATTENDEElines, each carrying aPARTSTAT=parameter and amailto: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=rsvpsruns with a user whereisCreatorreturnsfalse - THEN the response status is
403and the body carriescode: "EVENTS_PLUGIN.FORBIDDEN"
Scenario: invalid with value returns 422
- WHEN
GET /event:1/ics?with=bogusruns - THEN the response status is
422and the body carriescode: "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_CODESand grepssrc/for anew EventsPluginError(EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY, ...)orcode: EVENTS_PLUGIN_CODES.ICS_INVALID_QUERYcall 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
MeterProvideris set,resetEventsInstruments()is called, thenGET /event:1/icsreturns 200 - THEN the next
forceFlushof the in-memory reader containsevents.ics.served_totalwith attributewith=noneand value1
Scenario: 404 path increments failed_total
- WHEN the same setup runs and
GET /missing/icsreturns 404 - THEN the next
forceFlushcontainsevents.ics.failed_totalwith attributereason=not_foundand value1
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
onReadyruns against a fresh registry and app - THEN the app's
routesarray contains at least one entry whosepathends in/:id/ics
Verified by: src/plugin/plugin.test.ts::"onReady mounts the ICS export route"