@open-mercato/events 0.8.1-develop.7296.1.2111d779db → 0.9.0

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.
@@ -9,7 +9,9 @@
9
9
  */
10
10
 
11
11
  import { resolveRequestContext } from '@open-mercato/shared/lib/api/context'
12
+ import type { OrganizationScopeService } from '@open-mercato/shared/lib/auth/principal-service'
12
13
  import { createLogger } from '@open-mercato/shared/lib/logger'
14
+ import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'
13
15
  import { isBroadcastEvent } from '@open-mercato/shared/modules/events'
14
16
  import {
15
17
  CROSS_PROCESS_EVENT_INSTANCE_ID,
@@ -23,19 +25,133 @@ export const metadata = {
23
25
  }
24
26
 
25
27
  const HEARTBEAT_INTERVAL_MS = 30_000
28
+ const DEFAULT_AUTH_REVALIDATION_INTERVAL_MS = 30_000
29
+ const DEFAULT_CONNECTION_MAX_AGE_MS = 5 * 60_000
30
+ const MIN_AUTH_REVALIDATION_INTERVAL_MS = 1_000
31
+ const MIN_CONNECTION_MAX_AGE_MS = 1_000
32
+ const CONNECTION_MAX_AGE_JITTER_RATIO = 0.15
33
+ const MAX_TIMER_DELAY_MS = 2_147_483_647
26
34
  const MAX_PAYLOAD_BYTES = 4096
27
35
 
28
36
  const logger = createLogger('events').child({ component: 'stream' })
29
37
 
30
- type SseConnection = {
38
+ type SseConnectionIdentity = {
31
39
  tenantId: string
32
40
  organizationId: string | null
33
41
  userId: string
34
42
  roleIds: string[]
43
+ }
44
+
45
+ type SseConnection = {
46
+ identity: SseConnectionIdentity
35
47
  send: (data: string) => void
36
48
  close: () => void
37
49
  }
38
50
 
51
+ function resolveTimingConfig(): {
52
+ authRevalidationIntervalMs: number
53
+ connectionMaxAgeMs: number
54
+ } {
55
+ return {
56
+ authRevalidationIntervalMs: Math.min(
57
+ parseNumberWithDefault(
58
+ process.env.OM_EVENTS_SSE_AUTH_REVALIDATION_INTERVAL_MS,
59
+ DEFAULT_AUTH_REVALIDATION_INTERVAL_MS,
60
+ { integer: true, min: MIN_AUTH_REVALIDATION_INTERVAL_MS },
61
+ ),
62
+ MAX_TIMER_DELAY_MS,
63
+ ),
64
+ connectionMaxAgeMs: applyConnectionMaxAgeJitter(
65
+ parseNumberWithDefault(
66
+ process.env.OM_EVENTS_SSE_CONNECTION_MAX_AGE_MS,
67
+ DEFAULT_CONNECTION_MAX_AGE_MS,
68
+ { integer: true, min: MIN_CONNECTION_MAX_AGE_MS },
69
+ ),
70
+ ),
71
+ }
72
+ }
73
+
74
+ function applyConnectionMaxAgeJitter(configuredMs: number): number {
75
+ const jitterFactor = 1 + (Math.random() * 2 - 1) * CONNECTION_MAX_AGE_JITTER_RATIO
76
+ const jitteredMs = Math.round(configuredMs * jitterFactor)
77
+ return Math.min(Math.max(jitteredMs, MIN_CONNECTION_MAX_AGE_MS), MAX_TIMER_DELAY_MS)
78
+ }
79
+
80
+ function normalizeRoleIds(input: unknown): string[] {
81
+ if (!Array.isArray(input)) return []
82
+ return Array.from(new Set(
83
+ input
84
+ .filter((role): role is string => typeof role === 'string')
85
+ .map((role) => role.trim())
86
+ .filter((role) => role.length > 0),
87
+ )).sort((left, right) => left.localeCompare(right))
88
+ }
89
+
90
+ function resolveOrganizationScopeService(
91
+ ctx: Awaited<ReturnType<typeof resolveRequestContext>>['ctx'],
92
+ ): OrganizationScopeService | null {
93
+ try {
94
+ const service = ctx.container.resolve<OrganizationScopeService>('organizationScopeService')
95
+ return service && typeof service.resolveForRequest === 'function' ? service : null
96
+ } catch {
97
+ return null
98
+ }
99
+ }
100
+
101
+ function normalizeId(input: unknown): string | null {
102
+ return typeof input === 'string' && input.trim().length > 0 ? input.trim() : null
103
+ }
104
+
105
+ async function resolveConnectionIdentity(
106
+ ctx: Awaited<ReturnType<typeof resolveRequestContext>>['ctx'],
107
+ request: Request,
108
+ ): Promise<SseConnectionIdentity | null> {
109
+ const canonicalTenantId = normalizeId(ctx.auth?.tenantId)
110
+ if (!canonicalTenantId || !ctx.auth?.sub) return null
111
+ const organizationScopeService = resolveOrganizationScopeService(ctx)
112
+ if (!organizationScopeService) return null
113
+ const scope = await organizationScopeService.resolveForRequest({ auth: ctx.auth, request })
114
+ const tenantId = normalizeId(scope.tenantId)
115
+ // `selectedId` is null whenever the caller has "All organizations" selected —
116
+ // the default an unrestricted admin's organization switcher persists — so using
117
+ // it alone leaves the connection with no organization and `matchesAudience`
118
+ // then rejects EVERY organization-scoped event. Keep the home-organization
119
+ // fallback the bridge has always used; the allowed-set check below still
120
+ // refuses an organization the principal may not see.
121
+ const organizationId = normalizeId(scope.selectedId) ?? normalizeId(ctx.auth.orgId)
122
+ if (!tenantId || tenantId !== canonicalTenantId || scope.selectionRejected) return null
123
+ if (organizationId && Array.isArray(scope.allowedIds) && !scope.allowedIds.includes(organizationId)) {
124
+ return null
125
+ }
126
+ return {
127
+ tenantId,
128
+ organizationId,
129
+ userId: ctx.auth.sub,
130
+ roleIds: normalizeRoleIds(ctx.auth.roles),
131
+ }
132
+ }
133
+
134
+ function identitiesMatch(left: SseConnectionIdentity, right: SseConnectionIdentity): boolean {
135
+ return left.tenantId === right.tenantId
136
+ && left.organizationId === right.organizationId
137
+ && left.userId === right.userId
138
+ && left.roleIds.length === right.roleIds.length
139
+ && left.roleIds.every((roleId, index) => roleId === right.roleIds[index])
140
+ }
141
+
142
+ function hasApiKeyCredentials(req: Request): boolean {
143
+ if ((req.headers.get('x-api-key') ?? '').trim().length > 0) return true
144
+ const authorization = (req.headers.get('authorization') ?? '').trim()
145
+ return /^apikey(?:\s|$)/i.test(authorization)
146
+ }
147
+
148
+ function createAuthRevalidationRequest(req: Request): Request {
149
+ return new Request(req.url, {
150
+ method: req.method,
151
+ headers: req.headers,
152
+ })
153
+ }
154
+
39
155
  function collectStringValues(input: unknown): string[] {
40
156
  if (!Array.isArray(input)) return []
41
157
  const values: string[] = []
@@ -106,19 +222,19 @@ function normalizeAudience(data: Record<string, unknown>, options?: EmitOptions)
106
222
 
107
223
  function matchesAudience(conn: SseConnection, audience: ReturnType<typeof normalizeAudience>): boolean {
108
224
  if (!audience.tenantId) return false
109
- if (conn.tenantId !== audience.tenantId) return false
225
+ if (conn.identity.tenantId !== audience.tenantId) return false
110
226
 
111
227
  if (audience.organizationScopes.length > 0) {
112
- if (!conn.organizationId) return false
113
- if (!audience.organizationScopes.includes(conn.organizationId)) return false
228
+ if (!conn.identity.organizationId) return false
229
+ if (!audience.organizationScopes.includes(conn.identity.organizationId)) return false
114
230
  }
115
231
 
116
- if (audience.recipientUserScopes.length > 0 && !audience.recipientUserScopes.includes(conn.userId)) {
232
+ if (audience.recipientUserScopes.length > 0 && !audience.recipientUserScopes.includes(conn.identity.userId)) {
117
233
  return false
118
234
  }
119
235
 
120
236
  if (audience.recipientRoleScopes.length > 0) {
121
- const roleMatched = conn.roleIds.some((roleId) => audience.recipientRoleScopes.includes(roleId))
237
+ const roleMatched = conn.identity.roleIds.some((roleId) => audience.recipientRoleScopes.includes(roleId))
122
238
  if (!roleMatched) return false
123
239
  }
124
240
 
@@ -226,39 +342,48 @@ function ensureGlobalTapSubscription(): void {
226
342
  }
227
343
 
228
344
  export async function GET(req: Request): Promise<Response> {
345
+ if (hasApiKeyCredentials(req)) {
346
+ return new Response('Unauthorized', { status: 401 })
347
+ }
229
348
  const { ctx } = await resolveRequestContext(req)
230
-
231
- if (!ctx.auth?.tenantId || !ctx.auth?.sub) {
349
+ if (ctx.auth?.isApiKey === true) {
232
350
  return new Response('Unauthorized', { status: 401 })
233
351
  }
234
-
235
- const tenantId = ctx.auth.tenantId
236
- const organizationId = (ctx.selectedOrganizationId as string) ?? ctx.auth.orgId ?? null
237
- const userId = ctx.auth.sub
238
- const roleIds = Array.isArray(ctx.auth.roles)
239
- ? ctx.auth.roles.filter((role): role is string => typeof role === 'string' && role.trim().length > 0)
240
- : []
352
+ const initialIdentity = await resolveConnectionIdentity(ctx, req)
353
+ if (!initialIdentity) {
354
+ return new Response('Unauthorized', { status: 401 })
355
+ }
356
+ const authorizedIdentity = initialIdentity
357
+ const { authRevalidationIntervalMs, connectionMaxAgeMs } = resolveTimingConfig()
241
358
 
242
359
  ensureGlobalTapSubscription()
243
360
 
244
361
  const encoder = new TextEncoder()
245
362
  let heartbeatTimer: ReturnType<typeof setInterval> | null = null
363
+ let authRevalidationTimer: ReturnType<typeof setInterval> | null = null
364
+ let maxAgeTimer: ReturnType<typeof setTimeout> | null = null
365
+ let streamController: ReadableStreamDefaultController<Uint8Array> | null = null
246
366
  let connection: SseConnection | null = null
247
- const onAbort = () => cleanup()
367
+ let authRevalidationInFlight = false
368
+ let cleanedUp = false
369
+ const onAbort = () => closeConnection()
248
370
 
249
371
  const stream = new ReadableStream({
250
372
  start(controller) {
373
+ streamController = controller
251
374
  const send = (data: string) => {
252
- controller.enqueue(encoder.encode(`data: ${data}\n\n`))
375
+ if (cleanedUp) return
376
+ try {
377
+ streamController?.enqueue(encoder.encode(`data: ${data}\n\n`))
378
+ } catch {
379
+ closeConnection()
380
+ }
253
381
  }
254
382
 
255
383
  connection = {
256
- tenantId,
257
- organizationId,
258
- userId,
259
- roleIds,
384
+ identity: authorizedIdentity,
260
385
  send,
261
- close: () => controller.close(),
386
+ close: closeConnection,
262
387
  }
263
388
  connections.add(connection)
264
389
 
@@ -268,37 +393,86 @@ export async function GET(req: Request): Promise<Response> {
268
393
  // delays the browser EventSource `open` event — clients that gate work on
269
394
  // a "connected" signal would otherwise stall for up to 30s after mount.
270
395
  // Comment lines (`:` prefix) are ignored by EventSource message parsing.
271
- controller.enqueue(encoder.encode(': connected\n\n'))
396
+ streamController.enqueue(encoder.encode(': connected\n\n'))
272
397
 
273
398
  // Start heartbeat to keep connection alive
274
399
  heartbeatTimer = setInterval(() => {
275
400
  try {
276
- controller.enqueue(encoder.encode(':heartbeat\ndata: :heartbeat\n\n'))
401
+ streamController?.enqueue(encoder.encode(':heartbeat\ndata: :heartbeat\n\n'))
277
402
  } catch {
278
- // Stream may have been closed
403
+ closeConnection()
279
404
  }
280
405
  }, HEARTBEAT_INTERVAL_MS)
406
+
407
+ authRevalidationTimer = setInterval(() => {
408
+ void revalidateAuthorization()
409
+ }, authRevalidationIntervalMs)
410
+
411
+ maxAgeTimer = setTimeout(() => {
412
+ closeConnection()
413
+ }, connectionMaxAgeMs)
281
414
  },
282
415
  cancel() {
283
416
  cleanup()
284
417
  },
285
418
  })
286
419
 
420
+ async function revalidateAuthorization(): Promise<void> {
421
+ if (cleanedUp || authRevalidationInFlight) return
422
+ authRevalidationInFlight = true
423
+ try {
424
+ const validationRequest = createAuthRevalidationRequest(req)
425
+ const { ctx: freshCtx } = await resolveRequestContext(validationRequest)
426
+ if (cleanedUp) return
427
+ const freshIdentity = await resolveConnectionIdentity(freshCtx, validationRequest)
428
+ if (!freshIdentity || !identitiesMatch(authorizedIdentity, freshIdentity)) {
429
+ closeConnection()
430
+ return
431
+ }
432
+ if (connection) connection.identity = freshIdentity
433
+ } catch {
434
+ closeConnection()
435
+ } finally {
436
+ authRevalidationInFlight = false
437
+ }
438
+ }
439
+
440
+ function closeConnection(): void {
441
+ if (cleanedUp) return
442
+ const controller = streamController
443
+ cleanup()
444
+ try {
445
+ controller?.close()
446
+ } catch {}
447
+ }
448
+
287
449
  function cleanup() {
450
+ if (cleanedUp) return
451
+ cleanedUp = true
288
452
  if (heartbeatTimer) {
289
453
  clearInterval(heartbeatTimer)
290
454
  heartbeatTimer = null
291
455
  }
456
+ if (authRevalidationTimer) {
457
+ clearInterval(authRevalidationTimer)
458
+ authRevalidationTimer = null
459
+ }
460
+ if (maxAgeTimer) {
461
+ clearTimeout(maxAgeTimer)
462
+ maxAgeTimer = null
463
+ }
292
464
  if (connection) {
293
465
  connections.delete(connection)
294
466
  connection = null
295
467
  }
468
+ streamController = null
296
469
  // Detach from the request signal so reconnect churn does not accumulate
297
470
  // listeners and closures on long-lived AbortSignals.
298
471
  req.signal.removeEventListener('abort', onAbort)
299
472
  }
300
473
 
301
474
  req.signal.addEventListener('abort', onAbort, { once: true })
475
+ if (req.signal.aborted) onAbort()
302
476
 
303
477
  return new Response(stream, {
304
478
  status: 200,
@@ -314,7 +488,7 @@ export async function GET(req: Request): Promise<Response> {
314
488
  export const openApi = {
315
489
  GET: {
316
490
  summary: 'Subscribe to server events via SSE (DOM Event Bridge)',
317
- description: 'Long-lived SSE connection that receives server-side events marked with clientBroadcast: true. Events are server-filtered by tenant, organization, recipient user, and recipient role.',
491
+ description: 'Server-bounded SSE connection for staff cookie or Bearer authentication that receives server-side events marked with clientBroadcast: true. API-key credentials are not accepted. Events are server-filtered by tenant, organization, recipient user, and recipient role, with periodic canonical authorization revalidation.',
318
492
  tags: ['Events'],
319
493
  responses: {
320
494
  200: {