@pipeline-builder/api-server 4.1.22 → 4.1.24

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.
@@ -1,6 +1,7 @@
1
1
  import { type SseTicketStore } from '@pipeline-builder/api-core';
2
2
  import type { Response } from 'express';
3
3
  import type { SSERelay } from './sse-relay.js';
4
+ import { type CreateTicketResult } from './sse-ticket-broker.js';
4
5
  /**
5
6
  * Event types for SSE logging
6
7
  */
@@ -83,8 +84,8 @@ export interface SSEManagerOptions {
83
84
  */
84
85
  logStream?: boolean;
85
86
  /**
86
- * TTL for a stream-ownership binding (default: `SSE_STREAM_OWNER_TTL_MS` env or
87
- * 1h). Long enough to outlive a build; the producer re-binds as needed.
87
+ * TTL for a stream-ownership binding (default 1h). Long enough to outlive a
88
+ * build; the producer re-binds as needed.
88
89
  */
89
90
  streamOwnerTtlMs?: number;
90
91
  /**
@@ -110,14 +111,7 @@ export interface SSEManagerOptions {
110
111
  * both validate the subject identically.
111
112
  */
112
113
  export declare const SSE_REQUEST_ID_RE: RegExp;
113
- /** Result of {@link SSEManager.createTicket}. */
114
- export type CreateTicketResult = {
115
- ok: true;
116
- ticket: string;
117
- } | {
118
- ok: false;
119
- reason: 'org-limit' | 'capacity' | 'forbidden';
120
- };
114
+ export type { CreateTicketResult };
121
115
  /**
122
116
  * SSE Manager statistics
123
117
  */
@@ -150,17 +144,11 @@ export declare class SSEManager {
150
144
  * map mirrors `clients[].orgId` counts at all times. Orgs reach zero are
151
145
  * deleted to keep the map bounded. */
152
146
  private orgClientCounts;
153
- /** Injected ticket + stream-ownership backend, or the lazily built in-memory default. */
154
- private ticketStoreInstance?;
155
- /** True when this manager built its ticket store (so shutdown stops it). */
156
- private ownsTicketStore;
147
+ /** Ticket minting / redemption + stream ownership (see ./sse-ticket-broker.ts). */
148
+ private readonly tickets;
157
149
  private readonly maxClientsPerRequest;
158
150
  private readonly maxTotalClients;
159
151
  private readonly maxClientsPerOrg;
160
- private readonly maxTotalTickets;
161
- private readonly maxTicketsPerOrg;
162
- private readonly ticketTtlMs;
163
- private readonly streamOwnerTtlMs;
164
152
  private readonly clientTimeoutMs;
165
153
  private cleanupInterval;
166
154
  /** Cross-pod fan-out bus (undefined ⇒ local-only delivery). */
@@ -178,8 +166,6 @@ export declare class SSEManager {
178
166
  * factory returns null (Redis not configured → local-only delivery).
179
167
  */
180
168
  enableRelay(): void;
181
- /** The ticket store, building the in-memory default on first use. */
182
- private get ticketStore();
183
169
  /**
184
170
  * Handle a frame received from another pod via the relay. We already delivered
185
171
  * our OWN frames to local clients before publishing, so ignore our echo. A
@@ -199,76 +185,20 @@ export declare class SSEManager {
199
185
  /** Current open-stream count for an org (0 if unseen). */
200
186
  getOrgClientCount(orgId: string): number;
201
187
  /**
202
- * Canonical form of a requestId for equality checks: dashes stripped and
203
- * lowercased, so the dashed (api-server `uuid()`) and undashed (nginx
204
- * `$request_id`) renderings of the same id compare equal. Callers are
205
- * expected to have already format-validated against {@link SSE_REQUEST_ID_RE}.
206
- */
207
- private static normalizeRequestId;
208
- /**
209
- * Canonical form of an orgId for ownership equality checks: trimmed and
210
- * lowercased. The stream PRODUCER (bindStreamOwner) and the ticket-minting
211
- * CONSUMER (createTicket) run in different services, so a casing / whitespace
212
- * drift between how each renders the same org would otherwise make the owner
213
- * comparison in {@link createTicket} false-`forbidden` the real owner.
214
- * Normalizing both sides — mirroring {@link normalizeRequestId} — closes that.
215
- */
216
- private static normalizeOrgId;
217
- /**
218
- * Record the org that OWNS a stream subject. The stream PRODUCER calls this
219
- * when it creates a build-log stream so that ticket minting can assert the
220
- * caller's org owns the subject (see {@link createTicket}). Backed by the
221
- * injected ticket store, so when a Redis store is wired the platform producer
222
- * (a different service sharing the same Redis) can bind ownership that this
223
- * service reads — closing the cross-tenant attach gap where any org could mint
224
- * a ticket for a guessed requestId.
225
- *
226
- * @param requestId - The stream subject (format-validated by the caller).
227
- * @param orgId - The owning org (normalized internally via normalizeOrgId).
188
+ * Record the org that OWNS a stream subject, so ticket minting can refuse a
189
+ * cross-tenant mint. Delegates to the ticket broker.
228
190
  */
229
191
  bindStreamOwner(requestId: string, orgId: string): Promise<void>;
230
192
  /**
231
- * Mint a short-lived, single-use SSE ticket bound to `orgId` AND a specific
232
- * stream subject (`requestId`). Clients POST to obtain one (JWT-authenticated)
233
- * for the exact stream they intend to open, then open the EventSource with
234
- * `?ticket=<t>` so the JWT never lands in a query string / access log. The
235
- * ticket can then only be consumed to open that one subject's stream — see
236
- * {@link consumeTicket} — which is what enforces per-subject authorization.
237
- *
238
- * ORG-OWNERSHIP: if a stream owner has been bound for this subject (via
239
- * {@link bindStreamOwner}) and it is a DIFFERENT org, minting is refused
240
- * (`reason: 'forbidden'`) — an org cannot mint a ticket for another org's
241
- * stream even if it guesses the requestId. When no owner is bound (producer
242
- * wiring not yet present), minting falls back to binding the ticket to the
243
- * caller's own org, preserving current behavior.
244
- *
245
- * Bounded by the ticket store's live-ticket caps (total and per org). An
246
- * ownership lookup the store can't answer is refused as `capacity`.
247
- *
248
- * @param orgId - Owning org (normalized internally via normalizeOrgId).
249
- * @param requestId - The build-log stream subject this ticket authorizes.
250
- * The caller must have format-validated it (see {@link SSE_REQUEST_ID_RE}).
251
- * @returns `{ ok: true, ticket }` on success, or `{ ok: false, reason }`
252
- * where reason is `'org-limit'`, `'capacity'`, or `'forbidden'`.
193
+ * Mint a short-lived, single-use ticket bound to `orgId` AND this stream
194
+ * subject — how a browser opens an EventSource without putting its JWT in a
195
+ * query string. Delegates to the ticket broker, which owns the ownership and
196
+ * cap rules.
253
197
  */
254
198
  createTicket(orgId: string, requestId: string): Promise<CreateTicketResult>;
255
199
  /**
256
- * Validate and CONSUME a ticket for a specific stream subject. Single-use:
257
- * the ticket is deleted whether or not it turns out to be valid, so a replay
258
- * of the same value always fails. Returns the bound org on success, or null
259
- * when the ticket is unknown / already-used / expired / OR was minted for a
260
- * DIFFERENT `requestId` than the one being opened.
261
- *
262
- * The subject check is the authorization fix: without it, a ticket minted for
263
- * one stream could be presented to open ANY stream the holder's org could
264
- * name, letting an authenticated org attach to another org's log stream by
265
- * guessing its requestId. The generic 401 the caller returns does not
266
- * distinguish "unknown ticket" from "wrong subject", so it leaks nothing
267
- * about which requestIds exist.
268
- *
269
- * @param ticketId - The opaque ticket value from `?ticket=`.
270
- * @param requestId - The stream subject from the URL path (`:requestId`),
271
- * already format-validated by the caller.
200
+ * Validate and CONSUME a ticket for a specific stream subject (single-use,
201
+ * subject-bound). Delegates to the ticket broker.
272
202
  */
273
203
  consumeTicket(ticketId: string, requestId: string): Promise<{
274
204
  orgId: string;