@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +18 -0
- package/dist/broadcast-coalescer.js +124 -0
- package/dist/broadcast-coalescer.js.map +7 -0
- package/dist/bus.js +108 -11
- package/dist/bus.js.map +2 -2
- package/dist/index.js +12 -0
- package/dist/index.js.map +2 -2
- package/dist/modules/events/api/route.js +1 -0
- package/dist/modules/events/api/route.js.map +2 -2
- package/dist/modules/events/api/stream/route.js +161 -20
- package/dist/modules/events/api/stream/route.js.map +2 -2
- package/package.json +4 -5
- package/src/__tests__/broadcast-coalescer.test.ts +172 -0
- package/src/__tests__/broadcast-coalescing-bus.test.ts +409 -0
- package/src/broadcast-coalescer.ts +204 -0
- package/src/bus.ts +199 -19
- package/src/index.ts +10 -0
- package/src/modules/events/api/__tests__/route.test.ts +27 -4
- package/src/modules/events/api/route.ts +1 -0
- package/src/modules/events/api/stream/__tests__/route.test.ts +650 -8
- package/src/modules/events/api/stream/route.ts +200 -26
|
@@ -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
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
organizationId,
|
|
258
|
-
userId,
|
|
259
|
-
roleIds,
|
|
384
|
+
identity: authorizedIdentity,
|
|
260
385
|
send,
|
|
261
|
-
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
|
-
|
|
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
|
-
|
|
401
|
+
streamController?.enqueue(encoder.encode(':heartbeat\ndata: :heartbeat\n\n'))
|
|
277
402
|
} catch {
|
|
278
|
-
|
|
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: '
|
|
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: {
|