Problem
StreamingClient.publishEvent() (xstreamroll-sdk/src/client.ts) sends events to POST /streams/events, but the API has no such route. The full route inventory (api/src/streams/streams.controller.ts and every other controller) contains no POST /streams/events — the only ingestion-adjacent route is the worker's GET /streams/pending. An SDK consumer calling publishEvent() gets a 404 wrapped in ApiError every time. There is no way to get data into stream_data (the table the worker polls) over HTTP at all.
The SDK is the advertised integration surface: xstreamroll-sdk/README.md and the StreamingClient API present publishEvent as the primary method ("publishing events"), and the SDK integration tests (xstreamroll-sdk/__tests__/client.integration.test.ts) mock the route with nock (.post("/streams/events")), so the SDK's own suite passes while every real call fails.
This is the other half of the same hole as the missing POST /streams/processed route: the platform has a processing worker, a pending queue, and an event-replay API, but no HTTP surface for events to enter the system.
Meanwhile STREAM_API_KEY is mandatory (z.string().min(1, "STREAM_API_KEY is required") in api/src/config/env.ts), documented as "API key for stream authentication" (README, CONTRIBUTING.md, api/.env.example, k8s secrets), and enforced nowhere in the API — the natural enforcement point is the new ingestion endpoint.
Root cause
// xstreamroll-sdk/src/client.ts — publishEvent()
await this.requestJson<void>("/streams/events", { // ← route does not exist
method: "POST",
body: { clientId: this.clientId, ...event, timestamp: new Date().toISOString() },
})
Why this is architecturally hard
- The ingestion endpoint defines the shape of the
StreamEvent wire contract for the first time. packages/types/src/stream-event.ts already models StreamEvent/StreamEventRecord, but nothing on the server validates or stores an incoming event, so the endpoint must pick validation rules (which fields are required, size caps, schema versioning) and write them into the shared contract so SDK and API cannot drift.
- Authentication: the worker polls
GET /streams/pending unauthenticated ("In production this route should be firewalled to the internal service network" — controller JSDoc). A public ingest endpoint cannot be anonymous; deciding to gate it with the existing-but-unused STREAM_API_KEY env var is the smallest coherent step, and the key's storage (k8s Secret, docker-compose env, .env.example) already exists.
- Write path: events must land in
stream_data (the worker's poll source, api/src/streams/repository/streams-db.repository.ts getPendingEvents). The DB repository needs a matching insertPendingEvent method; the in-memory repository (api/src/streams/repository/streams.repository.ts) has no equivalent, so tests need one.
- The SDK's
publishEvent currently sends clientId + a timestamp it stamps itself. The server must decide whether to trust client timestamps (latency metrics in GET /streams/:id/analytics are computed from stream_events.processing_latency_ms, which derives from the event timestamp) — trusting client clocks is a correctness decision worth making deliberately.
Proposed design
POST /streams/events (single event) authenticated via X-Stream-Api-Key (or Authorization: Bearer <STREAM_API_KEY>), validating against a Zod/class-validator schema derived from packages/types' StreamEvent, inserting into stream_data (stream_id, data, timestamp).
- SDK: send the API key via
StreamConfig.apiKey, drop the client-stamped timestamp if the server stamps its own (or keep it, per the decision above), and keep publishEvent semantics.
- Add a contract test (see
tests/contracts/src/streams.contract.ts for the existing pattern) covering POST /streams/events so the SDK and API sides cannot drift.
Downstream impact
xstreamroll-sdk: StreamConfig (xstreamroll-sdk/src/types.ts) gains an apiKey-style field; publishEvent request shape may change. This is a public SDK surface change — bump the minor version and update the SDK README example.
packages/types: StreamEvent may need a server-accepted shape clarification (see the shared type in packages/types/src/stream-event.ts).
xstreamroll-sdk/__tests__/client.integration.test.ts: the nock mocks for /streams/events must reflect the final request shape.
- Contracts:
tests/contracts/src/streams.contract.ts gains the ingest contract; provider verification runs in api/src/contract-provider.spec.ts, consumer verification in xstreamroll-sdk/__tests__/contract.consumer.test.ts.
Acceptance criteria
Contract
Security
Tests
Documentation
Out of scope
Batched ingestion, an authenticated GET /streams/pending for external consumers, and replay/at-least-once guarantees on the ingest side (the worker dedupe story is tracked separately in the POST /streams/processed work).
Getting started
Real files in scope: api/src/streams/streams.controller.ts, api/src/streams/repository/streams-db.repository.ts, api/src/streams/repository/streams.repository.ts (in-memory), api/src/config/env.ts, xstreamroll-sdk/src/client.ts, xstreamroll-sdk/src/types.ts, xstreamroll-sdk/__tests__/client.integration.test.ts, tests/contracts/src/streams.contract.ts.
Verify with:
cd api && npm run typecheck && npm test
cd ../xstreamroll-sdk && npm run typecheck && npm test
npm run build --workspace=tests/contracts
Good first files to read: xstreamroll-sdk/src/client.ts (publishEvent), api/src/streams/repository/streams-db.repository.ts (getPendingEvents to mirror the insert), packages/types/src/stream-event.ts.
Problem
StreamingClient.publishEvent()(xstreamroll-sdk/src/client.ts) sends events toPOST /streams/events, but the API has no such route. The full route inventory (api/src/streams/streams.controller.tsand every other controller) contains noPOST /streams/events— the only ingestion-adjacent route is the worker'sGET /streams/pending. An SDK consumer callingpublishEvent()gets a 404 wrapped inApiErrorevery time. There is no way to get data intostream_data(the table the worker polls) over HTTP at all.The SDK is the advertised integration surface:
xstreamroll-sdk/README.mdand theStreamingClientAPI presentpublishEventas the primary method ("publishing events"), and the SDK integration tests (xstreamroll-sdk/__tests__/client.integration.test.ts) mock the route with nock (.post("/streams/events")), so the SDK's own suite passes while every real call fails.This is the other half of the same hole as the missing
POST /streams/processedroute: the platform has a processing worker, a pending queue, and an event-replay API, but no HTTP surface for events to enter the system.Meanwhile
STREAM_API_KEYis mandatory (z.string().min(1, "STREAM_API_KEY is required")inapi/src/config/env.ts), documented as "API key for stream authentication" (README,CONTRIBUTING.md,api/.env.example, k8s secrets), and enforced nowhere in the API — the natural enforcement point is the new ingestion endpoint.Root cause
Why this is architecturally hard
StreamEventwire contract for the first time.packages/types/src/stream-event.tsalready modelsStreamEvent/StreamEventRecord, but nothing on the server validates or stores an incoming event, so the endpoint must pick validation rules (which fields are required, size caps, schema versioning) and write them into the shared contract so SDK and API cannot drift.GET /streams/pendingunauthenticated ("In production this route should be firewalled to the internal service network" — controller JSDoc). A public ingest endpoint cannot be anonymous; deciding to gate it with the existing-but-unusedSTREAM_API_KEYenv var is the smallest coherent step, and the key's storage (k8s Secret, docker-compose env,.env.example) already exists.stream_data(the worker's poll source,api/src/streams/repository/streams-db.repository.tsgetPendingEvents). The DB repository needs a matchinginsertPendingEventmethod; the in-memory repository (api/src/streams/repository/streams.repository.ts) has no equivalent, so tests need one.publishEventcurrently sendsclientId+ atimestampit stamps itself. The server must decide whether to trust client timestamps (latency metrics inGET /streams/:id/analyticsare computed fromstream_events.processing_latency_ms, which derives from the event timestamp) — trusting client clocks is a correctness decision worth making deliberately.Proposed design
POST /streams/events(single event) authenticated viaX-Stream-Api-Key(orAuthorization: Bearer <STREAM_API_KEY>), validating against a Zod/class-validator schema derived frompackages/types'StreamEvent, inserting intostream_data(stream_id,data,timestamp).StreamConfig.apiKey, drop the client-stampedtimestampif the server stamps its own (or keep it, per the decision above), and keeppublishEventsemantics.tests/contracts/src/streams.contract.tsfor the existing pattern) coveringPOST /streams/eventsso the SDK and API sides cannot drift.Downstream impact
xstreamroll-sdk:StreamConfig(xstreamroll-sdk/src/types.ts) gains anapiKey-style field;publishEventrequest shape may change. This is a public SDK surface change — bump the minor version and update the SDK README example.packages/types:StreamEventmay need a server-accepted shape clarification (see the shared type inpackages/types/src/stream-event.ts).xstreamroll-sdk/__tests__/client.integration.test.ts: the nock mocks for/streams/eventsmust reflect the final request shape.tests/contracts/src/streams.contract.tsgains the ingest contract; provider verification runs inapi/src/contract-provider.spec.ts, consumer verification inxstreamroll-sdk/__tests__/contract.consumer.test.ts.Acceptance criteria
Contract
POST /streams/eventsexists, is Swagger-documented, and accepts a valid event, returning 2xx.GET /streams/pendingand, after the worker processes it, inGET /streams/:id/events.publishEvent()against a real API instance (not nock) returns success and the event is visible downstream.Security
STREAM_API_KEYwith 401, and the key is read from the same env var thatapi/src/config/env.tsvalidates.Tests
POST /streams/events→ row instream_data(use the harness inapi/src/database.integration.spec.ts).cd api && npm test,cd xstreamroll-sdk && npm test).clientId-only drift).Documentation
publishEventagainst the real endpoint, and theSTREAM_API_KEYusage is documented inapi/.env.exampleand README.Out of scope
Batched ingestion, an authenticated
GET /streams/pendingfor external consumers, and replay/at-least-once guarantees on the ingest side (the worker dedupe story is tracked separately in thePOST /streams/processedwork).Getting started
Real files in scope:
api/src/streams/streams.controller.ts,api/src/streams/repository/streams-db.repository.ts,api/src/streams/repository/streams.repository.ts(in-memory),api/src/config/env.ts,xstreamroll-sdk/src/client.ts,xstreamroll-sdk/src/types.ts,xstreamroll-sdk/__tests__/client.integration.test.ts,tests/contracts/src/streams.contract.ts.Verify with:
Good first files to read:
xstreamroll-sdk/src/client.ts(publishEvent),api/src/streams/repository/streams-db.repository.ts(getPendingEventsto mirror the insert),packages/types/src/stream-event.ts.