@pipeline-builder/api-server 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +79 -0
  3. package/lib/api/app-factory.d.ts +95 -0
  4. package/lib/api/app-factory.js +328 -0
  5. package/lib/api/check-quota.d.ts +13 -0
  6. package/lib/api/check-quota.js +74 -0
  7. package/lib/api/context-middleware.d.ts +34 -0
  8. package/lib/api/context-middleware.js +42 -0
  9. package/lib/api/etag-middleware.d.ts +7 -0
  10. package/lib/api/etag-middleware.js +44 -0
  11. package/lib/api/get-context.d.ts +22 -0
  12. package/lib/api/get-context.js +28 -0
  13. package/lib/api/health-checks.d.ts +62 -0
  14. package/lib/api/health-checks.js +87 -0
  15. package/lib/api/idempotency-middleware.d.ts +80 -0
  16. package/lib/api/idempotency-middleware.js +321 -0
  17. package/lib/api/index.d.ts +20 -0
  18. package/lib/api/index.js +34 -0
  19. package/lib/api/meter-quota.d.ts +32 -0
  20. package/lib/api/meter-quota.js +70 -0
  21. package/lib/api/metrics.d.ts +47 -0
  22. package/lib/api/metrics.js +221 -0
  23. package/lib/api/middleware-factory.d.ts +56 -0
  24. package/lib/api/middleware-factory.js +80 -0
  25. package/lib/api/middleware.d.ts +1 -0
  26. package/lib/api/middleware.js +8 -0
  27. package/lib/api/mongo-connect.d.ts +19 -0
  28. package/lib/api/mongo-connect.js +41 -0
  29. package/lib/api/quota-helpers.d.ts +23 -0
  30. package/lib/api/quota-helpers.js +22 -0
  31. package/lib/api/rate-limit-by-org.d.ts +29 -0
  32. package/lib/api/rate-limit-by-org.js +64 -0
  33. package/lib/api/readiness.d.ts +29 -0
  34. package/lib/api/readiness.js +65 -0
  35. package/lib/api/request-types.d.ts +55 -0
  36. package/lib/api/request-types.js +65 -0
  37. package/lib/api/require-org-id.d.ts +14 -0
  38. package/lib/api/require-org-id.js +28 -0
  39. package/lib/api/route-wrapper.d.ts +50 -0
  40. package/lib/api/route-wrapper.js +84 -0
  41. package/lib/api/server.d.ts +79 -0
  42. package/lib/api/server.js +234 -0
  43. package/lib/api/tenant-context.d.ts +28 -0
  44. package/lib/api/tenant-context.js +48 -0
  45. package/lib/api/tracing.d.ts +25 -0
  46. package/lib/api/tracing.js +69 -0
  47. package/lib/http/index.d.ts +4 -0
  48. package/lib/http/index.js +7 -0
  49. package/lib/http/sse-connection-manager.d.ts +357 -0
  50. package/lib/http/sse-connection-manager.js +665 -0
  51. package/lib/http/sse-relay.d.ts +63 -0
  52. package/lib/http/sse-relay.js +70 -0
  53. package/lib/http/sse-ticket-channel.d.ts +31 -0
  54. package/lib/http/sse-ticket-channel.js +52 -0
  55. package/lib/http/sse-ticket-store.d.ts +75 -0
  56. package/lib/http/sse-ticket-store.js +160 -0
  57. package/lib/index.d.ts +30 -0
  58. package/lib/index.js +35 -0
  59. package/lib/otel-bootstrap.d.ts +1 -0
  60. package/lib/otel-bootstrap.js +75 -0
  61. package/package.json +183 -0
@@ -0,0 +1,357 @@
1
+ import type { Response } from 'express';
2
+ import type { SSERelay } from './sse-relay.js';
3
+ import { type SSEStreamTicketStore } from './sse-ticket-store.js';
4
+ /**
5
+ * Event types for SSE logging
6
+ */
7
+ export type SSEEventType = 'INFO' | 'WARN' | 'ERROR' | 'COMPLETED' | 'ROLLBACK' | 'MESSAGE';
8
+ /**
9
+ * SSE payload structure
10
+ */
11
+ export interface SSEPayload {
12
+ ts: string;
13
+ type: SSEEventType;
14
+ message: string;
15
+ data?: unknown;
16
+ }
17
+ /**
18
+ * SSE client with connection tracking
19
+ */
20
+ export interface SSEClient {
21
+ id: string;
22
+ res: Response;
23
+ connectedAt: number;
24
+ timeout: NodeJS.Timeout;
25
+ /** Number of consecutive backpressure events (write returned false). */
26
+ backpressureCount: number;
27
+ /** Owning org id — populated when the request was authenticated. Used to
28
+ * decrement the per-org counter on disconnect / cleanup. */
29
+ orgId?: string;
30
+ }
31
+ /**
32
+ * SSE Manager configuration options
33
+ */
34
+ export interface SSEManagerOptions {
35
+ /** Maximum clients allowed per request ID (default: 10) */
36
+ maxClientsPerRequest?: number;
37
+ /** Client timeout in milliseconds (default: 30 minutes) */
38
+ clientTimeoutMs?: number;
39
+ /** Interval for cleanup checks in milliseconds (default: 5 minutes) */
40
+ cleanupIntervalMs?: number;
41
+ /**
42
+ * Hard cap on total open connections per process. Defaults to 1000 from
43
+ * `SSE_MAX_TOTAL_CLIENTS`. New connections beyond this are rejected at
44
+ * `addClient()`. Tune up if your service serves > 1000 concurrent SSE
45
+ * dashboards, but be aware Node.js fd limits dominate above ~5000.
46
+ */
47
+ maxTotalClients?: number;
48
+ /**
49
+ * Per-org ceiling on concurrent SSE streams. Defaults to 50 from
50
+ * `SSE_MAX_CLIENTS_PER_ORG`. Protects against one noisy org consuming the
51
+ * process-wide budget — without it, an authenticated user can fan out
52
+ * thousands of streams (one per dashboard / log stream) and starve other
53
+ * orgs of connection slots. The cap is enforced only on `addClient` calls
54
+ * that carry an `orgId`; service-internal / anonymous SSE traffic skips it.
55
+ */
56
+ maxClientsPerOrg?: number;
57
+ /**
58
+ * Hard cap on total live (unconsumed, unexpired) SSE tickets across the
59
+ * process. Defaults to 1000 from `SSE_MAX_TOTAL_TICKETS`. Bounds memory
60
+ * under abuse — `createTicket` returns `{ ok: false, reason: 'capacity' }`
61
+ * once reached.
62
+ */
63
+ maxTotalTickets?: number;
64
+ /**
65
+ * Per-org cap on live SSE tickets. Defaults to 10 from
66
+ * `SSE_MAX_TICKETS_PER_ORG`. Prevents a single tenant from saturating the
67
+ * ticket table — `createTicket` returns `{ ok: false, reason: 'org-limit' }`
68
+ * once reached.
69
+ */
70
+ maxTicketsPerOrg?: number;
71
+ /** Ticket TTL in ms (default: `SSE_TICKET_TTL_MS` from api-core). */
72
+ ticketTtlMs?: number;
73
+ /**
74
+ * Ticket + stream-ownership backend. Defaults to an in-memory store (correct
75
+ * single-replica only). Inject `createEnvRedisTicketStore()` (or a bespoke
76
+ * `SSEStreamTicketStore`) so ticket mint/redeem AND stream ownership work across
77
+ * horizontally-scaled pods and — critically — so the platform stream producer
78
+ * (a different service) can bind ownership that this service reads.
79
+ */
80
+ ticketStore?: SSEStreamTicketStore;
81
+ /**
82
+ * TTL for a stream-ownership binding (default: `SSE_STREAM_OWNER_TTL_MS` env or
83
+ * 1h). Long enough to outlive a build; the producer re-binds as needed.
84
+ */
85
+ streamOwnerTtlMs?: number;
86
+ /**
87
+ * Cross-pod fan-out bus for `send()`/`broadcast()`. Optional — when omitted,
88
+ * delivery is LOCAL-ONLY (correct single-replica; today's behavior). Inject
89
+ * `createEnvRedisSSERelay()` so a frame produced on one pod reaches an
90
+ * EventSource attached on a DIFFERENT pod. Degrades to local-only automatically
91
+ * when Redis is unset (the factory returns null) or during a Redis outage.
92
+ */
93
+ relay?: SSERelay;
94
+ }
95
+ /**
96
+ * A build-log stream subject id: either a dashed UUID (api-server's `uuid()`)
97
+ * or nginx's `$request_id` — 16 random bytes rendered as 32 hex chars with NO
98
+ * dashes. Accept both by making the group separators optional; still strictly
99
+ * 32 hex chars so it can't carry an injection / path-traversal payload into the
100
+ * SSE subject. Shared by the ticket-mint route and the stream middleware so
101
+ * both validate the subject identically.
102
+ */
103
+ export declare const SSE_REQUEST_ID_RE: RegExp;
104
+ /** Result of {@link SSEManager.createTicket}. */
105
+ export type CreateTicketResult = {
106
+ ok: true;
107
+ ticket: string;
108
+ } | {
109
+ ok: false;
110
+ reason: 'org-limit' | 'capacity' | 'forbidden';
111
+ };
112
+ /**
113
+ * SSE Manager statistics
114
+ */
115
+ export interface SSEManagerStats {
116
+ totalRequests: number;
117
+ totalClients: number;
118
+ oldestConnectionMs: number | null;
119
+ }
120
+ /**
121
+ * SSE helper class with memory leak protection
122
+ *
123
+ * Features:
124
+ * - Client limits per request ID
125
+ * - Automatic timeout for idle connections
126
+ * - Periodic cleanup of stale connections
127
+ * - Connection statistics
128
+ *
129
+ * @example
130
+ * ```typescript
131
+ * const sseManager = new SSEManager({ maxClientsPerRequest: 5 });
132
+ * app.get('/logs/:requestId', sseManager.middleware());
133
+ *
134
+ * // Send events
135
+ * sseManager.send('request-123', 'INFO', 'Processing...');
136
+ * ```
137
+ */
138
+ export declare class SSEManager {
139
+ private clients;
140
+ /** Per-org open-client counters. Decremented on removeClient/cleanup so the
141
+ * map mirrors `clients[].orgId` counts at all times. Orgs reach zero are
142
+ * deleted to keep the map bounded. */
143
+ private orgClientCounts;
144
+ /** Ticket + stream-ownership backend (in-memory default; Redis for multi-pod).
145
+ * Bounded by `maxTotalTickets` / `maxTicketsPerOrg`, enforced in createTicket;
146
+ * expiry is validated at consume time and swept on the cleanup interval. */
147
+ private readonly ticketStore;
148
+ private readonly maxClientsPerRequest;
149
+ private readonly maxTotalClients;
150
+ private readonly maxClientsPerOrg;
151
+ private readonly maxTotalTickets;
152
+ private readonly maxTicketsPerOrg;
153
+ private readonly ticketTtlMs;
154
+ private readonly streamOwnerTtlMs;
155
+ private readonly clientTimeoutMs;
156
+ private cleanupInterval;
157
+ /** Cross-pod fan-out bus (undefined ⇒ local-only delivery). */
158
+ private readonly relay?;
159
+ /** This manager instance's id — tags relayed frames so we ignore our own echo. */
160
+ private readonly nodeId;
161
+ constructor(options?: SSEManagerOptions);
162
+ /**
163
+ * Handle a frame received from another pod via the relay. We already delivered
164
+ * our OWN frames to local clients before publishing, so ignore our echo. A
165
+ * `send` re-emits to the subject's local clients; a `broadcast` to all of them.
166
+ * Purely local — never re-publishes, so there is no fan-out loop.
167
+ */
168
+ private onRelayMessage;
169
+ /** Total open connections across all requests. */
170
+ private totalClients;
171
+ /**
172
+ * Publish the current live-connection count as a gauge so on-call can see SSE
173
+ * saturation (approach to the per-process cap) on a dashboard — previously
174
+ * these numbers lived only in `getStats()`/logs. Called after every add/remove/
175
+ * close/cleanup. Cheap (O(R)); metric helpers never throw.
176
+ */
177
+ private updateActiveGauge;
178
+ /** Current open-stream count for an org (0 if unseen). */
179
+ getOrgClientCount(orgId: string): number;
180
+ /**
181
+ * Canonical form of a requestId for equality checks: dashes stripped and
182
+ * lowercased, so the dashed (api-server `uuid()`) and undashed (nginx
183
+ * `$request_id`) renderings of the same id compare equal. Callers are
184
+ * expected to have already format-validated against {@link SSE_REQUEST_ID_RE}.
185
+ */
186
+ private static normalizeRequestId;
187
+ /**
188
+ * Canonical form of an orgId for ownership equality checks: trimmed and
189
+ * lowercased. The stream PRODUCER (bindStreamOwner) and the ticket-minting
190
+ * CONSUMER (createTicket) run in different services, so a casing / whitespace
191
+ * drift between how each renders the same org would otherwise make the owner
192
+ * comparison in {@link createTicket} false-`forbidden` the real owner.
193
+ * Normalizing both sides — mirroring {@link normalizeRequestId} — closes that.
194
+ */
195
+ private static normalizeOrgId;
196
+ /**
197
+ * Record the org that OWNS a stream subject. The stream PRODUCER calls this
198
+ * when it creates a build-log stream so that ticket minting can assert the
199
+ * caller's org owns the subject (see {@link createTicket}). Backed by the
200
+ * injected ticket store, so when a Redis store is wired the platform producer
201
+ * (a different service sharing the same Redis) can bind ownership that this
202
+ * service reads — closing the cross-tenant attach gap where any org could mint
203
+ * a ticket for a guessed requestId.
204
+ *
205
+ * @param requestId - The stream subject (format-validated by the caller).
206
+ * @param orgId - The owning org (normalized internally via normalizeOrgId).
207
+ */
208
+ bindStreamOwner(requestId: string, orgId: string): Promise<void>;
209
+ /**
210
+ * Mint a short-lived, single-use SSE ticket bound to `orgId` AND a specific
211
+ * stream subject (`requestId`). Clients POST to obtain one (JWT-authenticated)
212
+ * for the exact stream they intend to open, then open the EventSource with
213
+ * `?ticket=<t>` so the JWT never lands in a query string / access log. The
214
+ * ticket can then only be consumed to open that one subject's stream — see
215
+ * {@link consumeTicket} — which is what enforces per-subject authorization.
216
+ *
217
+ * ORG-OWNERSHIP: if a stream owner has been bound for this subject (via
218
+ * {@link bindStreamOwner}) and it is a DIFFERENT org, minting is refused
219
+ * (`reason: 'forbidden'`) — an org cannot mint a ticket for another org's
220
+ * stream even if it guesses the requestId. When no owner is bound (producer
221
+ * wiring not yet present), minting falls back to binding the ticket to the
222
+ * caller's own org, preserving current behavior.
223
+ *
224
+ * Bounded by `maxTotalTickets` (process-wide) and `maxTicketsPerOrg`
225
+ * (per-tenant). Mirrors the message-service notifications ticket store.
226
+ *
227
+ * @param orgId - Owning org (normalized internally via normalizeOrgId).
228
+ * @param requestId - The build-log stream subject this ticket authorizes.
229
+ * The caller must have format-validated it (see {@link SSE_REQUEST_ID_RE}).
230
+ * @returns `{ ok: true, ticket }` on success, or `{ ok: false, reason }`
231
+ * where reason is `'org-limit'`, `'capacity'`, or `'forbidden'`.
232
+ */
233
+ createTicket(orgId: string, requestId: string): Promise<CreateTicketResult>;
234
+ /**
235
+ * Validate and CONSUME a ticket for a specific stream subject. Single-use:
236
+ * the ticket is deleted whether or not it turns out to be valid, so a replay
237
+ * of the same value always fails. Returns the bound org on success, or null
238
+ * when the ticket is unknown / already-used / expired / OR was minted for a
239
+ * DIFFERENT `requestId` than the one being opened.
240
+ *
241
+ * The subject check is the authorization fix: without it, a ticket minted for
242
+ * one stream could be presented to open ANY stream the holder's org could
243
+ * name, letting an authenticated org attach to another org's log stream by
244
+ * guessing its requestId. The generic 401 the caller returns does not
245
+ * distinguish "unknown ticket" from "wrong subject", so it leaks nothing
246
+ * about which requestIds exist.
247
+ *
248
+ * @param ticketId - The opaque ticket value from `?ticket=`.
249
+ * @param requestId - The stream subject from the URL path (`:requestId`),
250
+ * already format-validated by the caller.
251
+ */
252
+ consumeTicket(ticketId: string, requestId: string): Promise<{
253
+ orgId: string;
254
+ } | null>;
255
+ /**
256
+ * Adds a client to the SSE manager
257
+ *
258
+ * @param requestId - Unique request ID
259
+ * @param res - Express Response object
260
+ * @param orgId - Authenticated org id (optional). When set, enforces the
261
+ * per-org cap and the counter is decremented on disconnect/cleanup.
262
+ * @returns true if client was added, false if rejected (limit reached)
263
+ */
264
+ addClient(requestId: string, res: Response, orgId?: string): boolean;
265
+ /** Drop one from the per-org counter. Idempotent: a counter at 0 stays at 0
266
+ * and the org key is removed from the map. */
267
+ private decrementOrgCount;
268
+ /**
269
+ * Removes a client from the manager
270
+ */
271
+ private removeClient;
272
+ /**
273
+ * Sends a message to all SSE clients for a requestId
274
+ *
275
+ * @param requestId - Request ID
276
+ * @param type - Event type
277
+ * @param message - Message string
278
+ * @param data - Optional additional data
279
+ * @returns Number of clients the message was sent to
280
+ */
281
+ send(requestId: string, type: SSEEventType, message: string, data?: unknown): number;
282
+ /**
283
+ * Write a pre-built payload to this pod's LOCAL clients for `requestId`. Shared
284
+ * by {@link send} (local half) and the relay re-emit path ({@link onRelayMessage}),
285
+ * so a relayed frame reuses the producer's original `ts` rather than a new one.
286
+ * Never relays — callers decide whether to publish.
287
+ */
288
+ private sendLocal;
289
+ /**
290
+ * Broadcast a message to all connected clients across all requests
291
+ *
292
+ * @param type - Event type
293
+ * @param message - Message string
294
+ * @param data - Optional additional data
295
+ * @returns Total number of clients the message was sent to
296
+ */
297
+ broadcast(type: SSEEventType, message: string, data?: unknown): number;
298
+ /** Write a pre-built payload to all LOCAL clients across every request. */
299
+ private broadcastLocal;
300
+ /** Fire-and-forget relay publish. No-op (local-only) when no relay is wired. */
301
+ private publishRelay;
302
+ /**
303
+ * Close all clients for a specific request
304
+ *
305
+ * @param requestId - Request ID to close
306
+ * @param finalMessage - Optional final message to send before closing
307
+ */
308
+ closeRequest(requestId: string, finalMessage?: string): void;
309
+ /**
310
+ * Get statistics about current connections
311
+ */
312
+ getStats(): SSEManagerStats;
313
+ /**
314
+ * Check if a request has any connected clients
315
+ */
316
+ hasClients(requestId: string): boolean;
317
+ /**
318
+ * Get the number of clients for a specific request
319
+ */
320
+ getClientCount(requestId: string): number;
321
+ /**
322
+ * Middleware to initialize a ticket-authenticated SSE connection.
323
+ *
324
+ * The stream is NOT open to the world: the caller must first exchange its
325
+ * JWT for a short-lived single-use ticket (see {@link createTicket}, wired to
326
+ * `POST /logs/ticket`) and pass it as `?ticket=<t>`. The middleware validates
327
+ * + consumes the ticket, binds the connection to the ticket's org, and
328
+ * enforces the per-org connection cap. A missing / invalid / expired /
329
+ * already-used ticket is rejected with 401 before any SSE headers flush.
330
+ *
331
+ * @example
332
+ * ```typescript
333
+ * app.get('/logs/:requestId', sseManager.middleware());
334
+ * ```
335
+ */
336
+ middleware(): (req: {
337
+ params: {
338
+ requestId: string;
339
+ };
340
+ query?: {
341
+ ticket?: unknown;
342
+ };
343
+ }, res: Response) => Promise<void>;
344
+ /**
345
+ * Start periodic cleanup of stale connections
346
+ */
347
+ private startCleanupInterval;
348
+ /**
349
+ * Clean up stale connections using single-pass partition.
350
+ * O(R × C) instead of O(R × C²), and avoids mutation-during-iteration.
351
+ */
352
+ private cleanup;
353
+ /**
354
+ * Shutdown the SSE manager and close all connections
355
+ */
356
+ shutdown(): void;
357
+ }