@open-mercato/shared 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51

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 (81) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +4 -1
  3. package/dist/lib/auth/jwt.js +6 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/mfaPendingAccess.js +42 -0
  6. package/dist/lib/auth/mfaPendingAccess.js.map +7 -0
  7. package/dist/lib/auth/organizationAccess.js +7 -4
  8. package/dist/lib/auth/organizationAccess.js.map +2 -2
  9. package/dist/lib/auth/principal-service.js +1 -0
  10. package/dist/lib/auth/principal-service.js.map +7 -0
  11. package/dist/lib/auth/server.js +38 -7
  12. package/dist/lib/auth/server.js.map +2 -2
  13. package/dist/lib/commands/command-bus.js +6 -1
  14. package/dist/lib/commands/command-bus.js.map +2 -2
  15. package/dist/lib/crud/factory.js +24 -8
  16. package/dist/lib/crud/factory.js.map +2 -2
  17. package/dist/lib/data/engine.js +8 -2
  18. package/dist/lib/data/engine.js.map +2 -2
  19. package/dist/lib/html/htmlToPlainText.js +16 -0
  20. package/dist/lib/html/htmlToPlainText.js.map +7 -0
  21. package/dist/lib/location/countries.js +12 -0
  22. package/dist/lib/location/countries.js.map +2 -2
  23. package/dist/lib/openapi/crud.js +4 -1
  24. package/dist/lib/openapi/crud.js.map +2 -2
  25. package/dist/lib/query/count-cap.js +11 -0
  26. package/dist/lib/query/count-cap.js.map +7 -0
  27. package/dist/lib/query/engine.js +270 -34
  28. package/dist/lib/query/engine.js.map +3 -3
  29. package/dist/lib/query/types.js.map +1 -1
  30. package/dist/lib/queue/dispatchOrigin.js +20 -0
  31. package/dist/lib/queue/dispatchOrigin.js.map +7 -0
  32. package/dist/lib/search/config.js +1 -0
  33. package/dist/lib/search/config.js.map +2 -2
  34. package/dist/lib/search/entityAccess.js +44 -0
  35. package/dist/lib/search/entityAccess.js.map +7 -0
  36. package/dist/lib/version.js +1 -1
  37. package/dist/lib/version.js.map +1 -1
  38. package/dist/modules/events/factory.js +69 -15
  39. package/dist/modules/events/factory.js.map +2 -2
  40. package/dist/modules/registry.js +15 -0
  41. package/dist/modules/registry.js.map +2 -2
  42. package/dist/modules/widgets/component-registry.js.map +2 -2
  43. package/package.json +10 -3
  44. package/src/lib/auth/__tests__/jwt.test.ts +13 -0
  45. package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +69 -0
  46. package/src/lib/auth/__tests__/organizationAccess.test.ts +36 -1
  47. package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
  48. package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
  49. package/src/lib/auth/__tests__/server.test.ts +104 -0
  50. package/src/lib/auth/jwt.ts +17 -0
  51. package/src/lib/auth/mfaPendingAccess.ts +70 -0
  52. package/src/lib/auth/organizationAccess.ts +11 -3
  53. package/src/lib/auth/principal-service.ts +110 -0
  54. package/src/lib/auth/server.ts +78 -8
  55. package/src/lib/commands/__tests__/command-bus.test.ts +31 -0
  56. package/src/lib/commands/command-bus.ts +8 -1
  57. package/src/lib/crud/__tests__/crud-factory.test.ts +165 -0
  58. package/src/lib/crud/factory.ts +33 -7
  59. package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
  60. package/src/lib/data/engine.ts +7 -1
  61. package/src/lib/html/__tests__/htmlToPlainText.test.ts +59 -0
  62. package/src/lib/html/htmlToPlainText.ts +17 -0
  63. package/src/lib/location/__tests__/countries.test.ts +15 -0
  64. package/src/lib/location/countries.ts +17 -0
  65. package/src/lib/openapi/crud.ts +3 -0
  66. package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
  67. package/src/lib/query/__tests__/count-cap.test.ts +41 -0
  68. package/src/lib/query/__tests__/engine.count-distinct.test.ts +162 -15
  69. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
  70. package/src/lib/query/__tests__/engine.test.ts +445 -7
  71. package/src/lib/query/count-cap.ts +19 -0
  72. package/src/lib/query/engine.ts +434 -54
  73. package/src/lib/query/types.ts +15 -0
  74. package/src/lib/queue/dispatchOrigin.ts +35 -0
  75. package/src/lib/search/config.ts +10 -0
  76. package/src/lib/search/entityAccess.ts +132 -0
  77. package/src/modules/events/__tests__/factory.test.ts +88 -0
  78. package/src/modules/events/factory.ts +111 -19
  79. package/src/modules/events/types.ts +17 -0
  80. package/src/modules/registry.ts +40 -0
  81. package/src/modules/widgets/component-registry.ts +14 -0
@@ -151,12 +151,27 @@ export type EncryptedSortRowCapWarning = {
151
151
  entity: EntityId
152
152
  sortFields: string[]
153
153
  maxRows: number
154
+ /**
155
+ * Exact when `meta.listCountCapWarning` is absent; a floor when it is
156
+ * present (the list total itself was bounded at the cap).
157
+ */
154
158
  totalMatched: number
155
159
  }
156
160
 
161
+ /**
162
+ * Present when the list COUNT was bounded at `cap` matching rows
163
+ * (`OM_LIST_COUNT_CAP`): `total` is a floor, not an exact value. Surfaced on
164
+ * CRUD list payloads as `totalIsCapped: true`.
165
+ */
166
+ export type ListCountCapWarning = {
167
+ entity: EntityId
168
+ cap: number
169
+ }
170
+
157
171
  export type QueryResultMeta = {
158
172
  partialIndexWarning?: PartialIndexWarning
159
173
  encryptedSortRowCapWarning?: EncryptedSortRowCapWarning
174
+ listCountCapWarning?: ListCountCapWarning
160
175
  }
161
176
 
162
177
  export type QueryResult<T = any> = {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Trusted dispatch-origin markers for queue jobs.
3
+ *
4
+ * Jobs that drive privileged side effects (payment webhook processors and
5
+ * similar sinks) MUST only execute payloads enqueued by trusted infrastructure
6
+ * code paths. The scheduler dispatch layer and inbound webhook routes mark the
7
+ * payloads they enqueue; sensitive workers verify the marker before doing work.
8
+ *
9
+ * The scheduler payload sanitizer strips every caller-supplied `_`-prefixed
10
+ * key from scheduled target payloads, so a marker cannot be forged through the
11
+ * scheduler job API. Direct queue/Redis write access is operator-level access
12
+ * and outside this threat model.
13
+ */
14
+
15
+ export const QUEUE_JOB_ORIGIN_KEY = '_jobOrigin'
16
+
17
+ export type QueueJobOrigin = 'inbound-webhook' | 'scheduler'
18
+
19
+ export function markQueueJobOrigin<T extends Record<string, unknown>>(
20
+ payload: T,
21
+ origin: QueueJobOrigin,
22
+ ): T {
23
+ return { ...payload, [QUEUE_JOB_ORIGIN_KEY]: origin }
24
+ }
25
+
26
+ export function readQueueJobOrigin(payload: unknown): QueueJobOrigin | null {
27
+ if (!payload || typeof payload !== 'object') return null
28
+ const value = (payload as Record<string, unknown>)[QUEUE_JOB_ORIGIN_KEY]
29
+ if (value === 'inbound-webhook' || value === 'scheduler') return value
30
+ return null
31
+ }
32
+
33
+ export function isTrustedWebhookDispatch(payload: unknown): boolean {
34
+ return readQueueJobOrigin(payload) === 'inbound-webhook'
35
+ }
@@ -8,6 +8,15 @@ export type SearchConfig = {
8
8
  enablePartials: boolean
9
9
  hashAlgorithm: 'sha256' | 'sha1' | 'md5'
10
10
  storeRawTokens: boolean
11
+ /**
12
+ * When true, a like/ilike on a PLAINTEXT base column runs as exact SQL ILIKE instead of being
13
+ * rewritten into an approximate search-token match; encrypted columns always keep the token
14
+ * path (ILIKE against ciphertext cannot match). Off by default: token matching can be faster
15
+ * than an unanchored ILIKE, which may need a full scan without a trigram index — but it is
16
+ * approximate (fragments under minTokenLength vanish, so `ZK 1/2026` degrades to its year and
17
+ * an all-short term drops the predicate). Flip it on when list search must be exact.
18
+ */
19
+ useIlikeForNonEncryptedFields?: boolean
11
20
  blocklistedFields: string[]
12
21
  entityBlocklistedFields?: Record<string, string[]>
13
22
  maxFieldChars?: number
@@ -112,6 +121,7 @@ export function resolveSearchConfig(): SearchConfig {
112
121
  enablePartials: parseBoolean(process.env.OM_SEARCH_ENABLE_PARTIAL, true),
113
122
  hashAlgorithm: parseHashAlgorithm(process.env.OM_SEARCH_HASH_ALGO),
114
123
  storeRawTokens: parseBoolean(process.env.OM_SEARCH_STORE_RAW_TOKENS, false),
124
+ useIlikeForNonEncryptedFields: parseBoolean(process.env.OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS, false),
115
125
  blocklistedFields: blocklist.global,
116
126
  entityBlocklistedFields: blocklist.byEntity,
117
127
  maxFieldChars: parseNumber(process.env.OM_SEARCH_MAX_FIELD_CHARS, DEFAULT_SEARCH_MAX_FIELD_CHARS, 0),
@@ -0,0 +1,132 @@
1
+ import type { SearchEntityConfig } from '../../modules/search'
2
+ import { authorizeFeatures } from '../../security/featurePolicy'
3
+
4
+ /**
5
+ * Minimal shape of the `searchIndexer` DI service consumed by per-entity ACL
6
+ * resolution. Kept structural so callers and tests can pass a plain object
7
+ * instead of constructing a full `SearchIndexer`.
8
+ */
9
+ export type SearchEntityConfigLookup = {
10
+ getEntityConfig: (entityId: string) => SearchEntityConfig | undefined
11
+ getAllEntityConfigs: () => SearchEntityConfig[]
12
+ }
13
+
14
+ export type SearchEntityAccessSubject = {
15
+ grantedFeatures: readonly string[]
16
+ isSuperAdmin?: boolean
17
+ }
18
+
19
+ export type SearchEntityDenyReason =
20
+ /** No module declares this entity in a `search.ts` config. */
21
+ | 'unconfigured'
22
+ /** The entity is configured for search but declares no `aclFeatures`. */
23
+ | 'no-acl-features'
24
+ /** The caller does not hold the entity's declared view feature(s). */
25
+ | 'insufficient-features'
26
+
27
+ export type SearchEntityAccessOptions = {
28
+ /**
29
+ * Called once per denied entity type. Exists so a silent drop is diagnosable:
30
+ * results disappearing because a module forgot to declare `aclFeatures` looks
31
+ * identical, from the palette, to results that simply did not match.
32
+ */
33
+ onDeny?: (entityId: string, reason: SearchEntityDenyReason) => void
34
+ }
35
+
36
+ /**
37
+ * Decide whether a caller may see results for one entity type.
38
+ *
39
+ * The single `search.global` gate on the palette only says "this user may use
40
+ * global search"; it says nothing about which records they may read. Each entity
41
+ * declares the owning module's view feature(s) in `aclFeatures`, and those are
42
+ * what actually authorize the read — the same rule the `search_get` /
43
+ * `search_aggregate` AI tools already apply.
44
+ *
45
+ * Fails closed: an entity that is not registered for search, or that declares no
46
+ * `aclFeatures`, is never exposed to a non-superadmin caller.
47
+ */
48
+ export function canReadSearchEntity(
49
+ entityId: string,
50
+ lookup: SearchEntityConfigLookup,
51
+ subject: SearchEntityAccessSubject,
52
+ options: SearchEntityAccessOptions = {},
53
+ ): boolean {
54
+ if (subject.isSuperAdmin) return true
55
+
56
+ const config = lookup.getEntityConfig(entityId)
57
+ if (!config) {
58
+ options.onDeny?.(entityId, 'unconfigured')
59
+ return false
60
+ }
61
+
62
+ const required = config.aclFeatures
63
+ if (!required || required.length === 0) {
64
+ options.onDeny?.(entityId, 'no-acl-features')
65
+ return false
66
+ }
67
+
68
+ const allowed = authorizeFeatures(required, {
69
+ grantedFeatures: subject.grantedFeatures,
70
+ unrestricted: false,
71
+ })
72
+ if (!allowed) options.onDeny?.(entityId, 'insufficient-features')
73
+ return allowed
74
+ }
75
+
76
+ /**
77
+ * The entity types this caller may read, narrowed to `requestedEntityTypes` when
78
+ * the caller asked for specific ones.
79
+ *
80
+ * Restricting the query up front is what keeps `limit` meaningful. Filtering only
81
+ * after the search would spend the whole result budget on records the caller
82
+ * cannot see: an employee granted just `customers.people.view` would get the top
83
+ * 50 hits across every entity type, then watch most of them be dropped, and the
84
+ * palette would look empty even with hundreds of matching people behind it.
85
+ *
86
+ * Returns `undefined` when no restriction applies (superadmin with no explicit
87
+ * request), and an empty array when nothing is readable — callers should
88
+ * short-circuit on that rather than pass it down as "no filter".
89
+ */
90
+ export function resolveReadableEntityTypes(
91
+ lookup: SearchEntityConfigLookup,
92
+ subject: SearchEntityAccessSubject,
93
+ requestedEntityTypes?: string[],
94
+ ): string[] | undefined {
95
+ if (subject.isSuperAdmin) return requestedEntityTypes
96
+
97
+ const readable = lookup
98
+ .getAllEntityConfigs()
99
+ .filter((config) => config.enabled !== false)
100
+ .map((config) => config.entityId)
101
+ .filter((entityId) => canReadSearchEntity(entityId, lookup, subject))
102
+
103
+ if (!requestedEntityTypes) return readable
104
+ const requested = new Set(requestedEntityTypes)
105
+ return readable.filter((entityId) => requested.has(entityId))
106
+ }
107
+
108
+ /**
109
+ * Drop the results whose entity type the caller is not allowed to read.
110
+ *
111
+ * Filtering happens server-side so an under-privileged caller never receives the
112
+ * presenter title, subtitle or deep link of a record they cannot open. Decisions
113
+ * are memoized per entity type because a single response commonly mixes dozens of
114
+ * results across a handful of types.
115
+ */
116
+ export function filterSearchResultsByEntityAccess<T extends { entityId: string }>(
117
+ results: readonly T[],
118
+ lookup: SearchEntityConfigLookup,
119
+ subject: SearchEntityAccessSubject,
120
+ options: SearchEntityAccessOptions = {},
121
+ ): T[] {
122
+ if (subject.isSuperAdmin) return [...results]
123
+
124
+ const decisions = new Map<string, boolean>()
125
+ return results.filter((result) => {
126
+ const cached = decisions.get(result.entityId)
127
+ if (cached !== undefined) return cached
128
+ const allowed = canReadSearchEntity(result.entityId, lookup, subject, options)
129
+ decisions.set(result.entityId, allowed)
130
+ return allowed
131
+ })
132
+ }
@@ -0,0 +1,88 @@
1
+ const GLOBAL_EVENT_REGISTRY_KEY = '__openMercatoEventDefinitionRegistry__'
2
+
3
+ type EventFactoryModule = typeof import('../factory')
4
+
5
+ describe('event definition registry', () => {
6
+ const globalScope = globalThis as Record<string, unknown>
7
+ const originalRegistry = globalScope[GLOBAL_EVENT_REGISTRY_KEY]
8
+
9
+ beforeEach(() => {
10
+ delete globalScope[GLOBAL_EVENT_REGISTRY_KEY]
11
+ })
12
+
13
+ afterAll(() => {
14
+ if (originalRegistry === undefined) {
15
+ delete globalScope[GLOBAL_EVENT_REGISTRY_KEY]
16
+ } else {
17
+ globalScope[GLOBAL_EVENT_REGISTRY_KEY] = originalRegistry
18
+ }
19
+ })
20
+
21
+ it('shares declarations and registered configs across isolated module instances', () => {
22
+ let firstInstance: EventFactoryModule | undefined
23
+ let secondInstance: EventFactoryModule | undefined
24
+
25
+ jest.isolateModules(() => {
26
+ firstInstance = require('../factory') as EventFactoryModule
27
+ const config = firstInstance.createModuleEvents({
28
+ moduleId: 'isolated_events_test',
29
+ events: [{
30
+ id: 'isolated_events_test.invalidated',
31
+ label: 'Invalidated',
32
+ crossProcessBroadcast: true,
33
+ }] as const,
34
+ })
35
+ firstInstance.registerEventModuleConfigs([config])
36
+ })
37
+
38
+ jest.isolateModules(() => {
39
+ secondInstance = require('../factory') as EventFactoryModule
40
+ })
41
+
42
+ expect(secondInstance).not.toBe(firstInstance)
43
+ expect(secondInstance?.isEventDeclared('isolated_events_test.invalidated')).toBe(true)
44
+ expect(secondInstance?.isCrossProcessBroadcastEvent('isolated_events_test.invalidated')).toBe(true)
45
+ expect(secondInstance?.getDeclaredEvents()).toEqual(expect.arrayContaining([
46
+ expect.objectContaining({
47
+ id: 'isolated_events_test.invalidated',
48
+ module: 'isolated_events_test',
49
+ }),
50
+ ]))
51
+ expect(secondInstance?.getEventModuleConfigs()).toHaveLength(1)
52
+ })
53
+
54
+ it('refreshes a module definition during HMR without duplicating its event id', () => {
55
+ let firstInstance: EventFactoryModule | undefined
56
+ let secondInstance: EventFactoryModule | undefined
57
+
58
+ jest.isolateModules(() => {
59
+ firstInstance = require('../factory') as EventFactoryModule
60
+ firstInstance.createModuleEvents({
61
+ moduleId: 'hmr_events_test',
62
+ events: [{ id: 'hmr_events_test.changed', label: 'Before' }] as const,
63
+ })
64
+ })
65
+
66
+ jest.isolateModules(() => {
67
+ secondInstance = require('../factory') as EventFactoryModule
68
+ secondInstance.createModuleEvents({
69
+ moduleId: 'hmr_events_test',
70
+ events: [{
71
+ id: 'hmr_events_test.changed',
72
+ label: 'After',
73
+ clientBroadcast: true,
74
+ }] as const,
75
+ })
76
+ })
77
+
78
+ expect(firstInstance?.isBroadcastEvent('hmr_events_test.changed')).toBe(true)
79
+ expect(firstInstance?.getAllDeclaredEventIds()).toEqual(['hmr_events_test.changed'])
80
+ expect(firstInstance?.getDeclaredEvents()).toEqual([
81
+ expect.objectContaining({
82
+ id: 'hmr_events_test.changed',
83
+ label: 'After',
84
+ clientBroadcast: true,
85
+ }),
86
+ ])
87
+ })
88
+ })
@@ -65,17 +65,54 @@ export function getGlobalEventBus(): GlobalEventBus | null {
65
65
  // Event Registry for Validation
66
66
  // =============================================================================
67
67
 
68
- // Global set of all declared event IDs for runtime validation
69
- const allDeclaredEventIds = new Set<string>()
68
+ type EventRegistryState = {
69
+ declaredEventIds: Set<string>
70
+ declaredEvents: EventDefinition[]
71
+ registeredEventConfigs: EventModuleConfig[] | null
72
+ }
73
+
74
+ const GLOBAL_EVENT_REGISTRY_KEY = '__openMercatoEventDefinitionRegistry__'
75
+
76
+ const fallbackEventRegistryState: EventRegistryState = {
77
+ declaredEventIds: new Set<string>(),
78
+ declaredEvents: [],
79
+ registeredEventConfigs: null,
80
+ }
81
+
82
+ function isEventRegistryState(value: unknown): value is EventRegistryState {
83
+ if (!value || typeof value !== 'object') return false
84
+ const candidate = value as Partial<EventRegistryState>
85
+ return candidate.declaredEventIds instanceof Set
86
+ && Array.isArray(candidate.declaredEvents)
87
+ && (candidate.registeredEventConfigs === null || Array.isArray(candidate.registeredEventConfigs))
88
+ }
70
89
 
71
- // Global registry of all declared events with their full definitions
72
- const allDeclaredEvents: EventDefinition[] = []
90
+ function getEventRegistryState(): EventRegistryState {
91
+ try {
92
+ const globalScope = globalThis as Record<string, unknown>
93
+ const existing = globalScope[GLOBAL_EVENT_REGISTRY_KEY]
94
+ if (isEventRegistryState(existing)) return existing
95
+ globalScope[GLOBAL_EVENT_REGISTRY_KEY] = fallbackEventRegistryState
96
+ return fallbackEventRegistryState
97
+ } catch {
98
+ // Restricted runtimes may deny global access. Keep the previous
99
+ // module-local behavior as a safe fallback.
100
+ return fallbackEventRegistryState
101
+ }
102
+ }
73
103
 
74
104
  function addDeclaredEvent(event: EventDefinition): void {
75
- allDeclaredEventIds.add(event.id)
76
- // Avoid duplicates if createModuleEvents/registerEventModuleConfigs is called multiple times (e.g., HMR)
77
- if (!allDeclaredEvents.find(e => e.id === event.id)) {
78
- allDeclaredEvents.push(event)
105
+ const state = getEventRegistryState()
106
+ state.declaredEventIds.add(event.id)
107
+ const existingIndex = state.declaredEvents.findIndex((candidate) => candidate.id === event.id)
108
+ if (existingIndex < 0) {
109
+ state.declaredEvents.push(event)
110
+ return
111
+ }
112
+ // Refresh a module's own definition in place during HMR without allowing a
113
+ // duplicate declaration from another module to take over the event id.
114
+ if (state.declaredEvents[existingIndex]?.module === event.module) {
115
+ state.declaredEvents[existingIndex] = event
79
116
  }
80
117
  }
81
118
 
@@ -84,7 +121,7 @@ function addDeclaredEvent(event: EventDefinition): void {
84
121
  * Used for runtime validation to ensure only declared events are emitted.
85
122
  */
86
123
  export function isEventDeclared(eventId: string): boolean {
87
- return allDeclaredEventIds.has(eventId)
124
+ return getEventRegistryState().declaredEventIds.has(eventId)
88
125
  }
89
126
 
90
127
  /**
@@ -92,7 +129,7 @@ export function isEventDeclared(eventId: string): boolean {
92
129
  * Useful for debugging and introspection.
93
130
  */
94
131
  export function getAllDeclaredEventIds(): string[] {
95
- return Array.from(allDeclaredEventIds)
132
+ return Array.from(getEventRegistryState().declaredEventIds)
96
133
  }
97
134
 
98
135
  /**
@@ -100,7 +137,7 @@ export function getAllDeclaredEventIds(): string[] {
100
137
  * Used by the API to return available events for workflow triggers.
101
138
  */
102
139
  export function getDeclaredEvents(): EventDefinition[] {
103
- return [...allDeclaredEvents]
140
+ return [...getEventRegistryState().declaredEvents]
104
141
  }
105
142
 
106
143
  /**
@@ -108,16 +145,52 @@ export function getDeclaredEvents(): EventDefinition[] {
108
145
  * Used by the SSE endpoint to filter events for the DOM Event Bridge.
109
146
  */
110
147
  export function isBroadcastEvent(eventId: string): boolean {
111
- const event = allDeclaredEvents.find(e => e.id === eventId)
148
+ const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
112
149
  return event?.clientBroadcast === true
113
150
  }
114
151
 
152
+ /**
153
+ * Check if an event should be published over the server-to-server event bridge.
154
+ * Browser-broadcast events remain eligible for backward compatibility, while
155
+ * crossProcessBroadcast supports private process coordination without SSE.
156
+ */
157
+ export function isCrossProcessBroadcastEvent(eventId: string): boolean {
158
+ const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
159
+ return event?.clientBroadcast === true || event?.crossProcessBroadcast === true
160
+ }
161
+
162
+ /**
163
+ * Check whether an event is reserved for private server-to-server
164
+ * coordination. Workflow-authored EMIT_EVENT activities must not emit these
165
+ * events because their payload and event id are tenant-managed input.
166
+ */
167
+ export function isPrivateCrossProcessBroadcastEvent(eventId: string): boolean {
168
+ const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
169
+ return event?.crossProcessBroadcast === true && event?.clientBroadcast !== true
170
+ }
171
+
172
+ /**
173
+ * Verify provenance for a private cross-process event. The module id is
174
+ * stamped by a declared module emitter or another trusted server-side seam;
175
+ * tenant-managed event payloads never participate in this decision.
176
+ */
177
+ export function isPrivateCrossProcessEventEmitter(
178
+ eventId: string,
179
+ emitterModuleId: string | undefined,
180
+ ): boolean {
181
+ const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
182
+ if (event?.crossProcessBroadcast !== true) return true
183
+ return typeof event.module === 'string'
184
+ && event.module.length > 0
185
+ && event.module === emitterModuleId
186
+ }
187
+
115
188
  /**
116
189
  * Check if an event has portalBroadcast enabled.
117
190
  * Used by the portal SSE endpoint to filter events for the Portal Event Bridge.
118
191
  */
119
192
  export function isPortalBroadcastEvent(eventId: string): boolean {
120
- const event = allDeclaredEvents.find(e => e.id === eventId)
193
+ const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
121
194
  return event?.portalBroadcast === true
122
195
  }
123
196
 
@@ -125,17 +198,16 @@ export function isPortalBroadcastEvent(eventId: string): boolean {
125
198
  // Bootstrap Registration (similar to searchModuleConfigs pattern)
126
199
  // =============================================================================
127
200
 
128
- let _registeredEventConfigs: EventModuleConfig[] | null = null
129
-
130
201
  /**
131
202
  * Register event module configurations globally.
132
203
  * Called during app bootstrap with configs from events.generated.ts.
133
204
  */
134
205
  export function registerEventModuleConfigs(configs: EventModuleConfig[]): void {
135
- if (_registeredEventConfigs !== null && process.env.NODE_ENV === 'development') {
206
+ const state = getEventRegistryState()
207
+ if (state.registeredEventConfigs !== null && process.env.NODE_ENV === 'development') {
136
208
  logger.debug('Event module configs re-registered (this may occur during HMR)')
137
209
  }
138
- _registeredEventConfigs = configs
210
+ state.registeredEventConfigs = configs
139
211
  for (const config of configs) {
140
212
  for (const event of config.events) {
141
213
  addDeclaredEvent(event)
@@ -148,7 +220,7 @@ export function registerEventModuleConfigs(configs: EventModuleConfig[]): void {
148
220
  * Returns empty array if not registered.
149
221
  */
150
222
  export function getEventModuleConfigs(): EventModuleConfig[] {
151
- return _registeredEventConfigs ?? []
223
+ return getEventRegistryState().registeredEventConfigs ?? []
152
224
  }
153
225
 
154
226
  // =============================================================================
@@ -239,7 +311,27 @@ export function createModuleEvents<
239
311
  return
240
312
  }
241
313
 
242
- await eventBus.emit(eventId, payload, emitOptions)
314
+ const eventDefinition = fullEvents.find((event) => event.id === eventId)
315
+ const isClientBroadcast = eventDefinition?.clientBroadcast === true
316
+ const trustedOptions = eventDefinition?.crossProcessBroadcast === true || isClientBroadcast
317
+ ? {
318
+ ...emitOptions,
319
+ // Browser-broadcast module emitters historically accepted scope in
320
+ // their typed payload. Preserve that contract at the trusted module
321
+ // boundary while the event bus itself relies only on options.
322
+ ...(isClientBroadcast && emitOptions?.tenantId === undefined
323
+ ? { tenantId: payload.tenantId ?? null }
324
+ : {}),
325
+ ...(isClientBroadcast && emitOptions?.organizationId === undefined
326
+ ? { organizationId: payload.organizationId ?? null }
327
+ : {}),
328
+ ...(isClientBroadcast && emitOptions?.organizationIds === undefined && Array.isArray(payload.organizationIds)
329
+ ? { organizationIds: payload.organizationIds.filter((value): value is string => typeof value === 'string') }
330
+ : {}),
331
+ emitterModuleId: moduleId,
332
+ }
333
+ : emitOptions
334
+ await eventBus.emit(eventId, payload, trustedOptions)
243
335
  }
244
336
 
245
337
  return {
@@ -34,6 +34,8 @@ export interface EventDefinition {
34
34
  excludeFromTriggers?: boolean
35
35
  /** When true, this event is bridged to the browser via SSE (DOM Event Bridge). Default: false */
36
36
  clientBroadcast?: boolean
37
+ /** When true, this event is bridged between server processes without enabling browser delivery. Default: false */
38
+ crossProcessBroadcast?: boolean
37
39
  /** When true, this event is bridged to the customer portal via SSE (Portal Event Bridge). Default: false */
38
40
  portalBroadcast?: boolean
39
41
  }
@@ -66,6 +68,21 @@ export interface EmitOptions {
66
68
  tenantId?: string | null
67
69
  /** Trusted organization scope forwarded to subscribers separately from the payload */
68
70
  organizationId?: string | null
71
+ /**
72
+ * Trusted multi-organization audience forwarded to subscribers separately
73
+ * from the payload. Mirrors the documented SSE audience contract, where a
74
+ * clientBroadcast event may target several organizations at once; carried in
75
+ * trusted options so the receiver never falls back to payload scope.
76
+ */
77
+ organizationIds?: string[] | null
78
+ /**
79
+ * Module that emitted a private cross-process coordination event.
80
+ *
81
+ * This is stamped by `createModuleEvents`; application inputs must not set or
82
+ * derive it from event payloads.
83
+ * @internal
84
+ */
85
+ emitterModuleId?: string
69
86
  }
70
87
 
71
88
  // =============================================================================
@@ -196,7 +196,19 @@ export type ModuleWorker = {
196
196
  concurrency: number
197
197
  lockDuration?: number
198
198
  maxStalledCount?: number
199
+ /**
200
+ * Reports a job the queue abandoned without running the handler.
201
+ *
202
+ * Present only for workers whose metadata declares it; the generator emits it as a lazy import
203
+ * beside the handler, because the registry serializes metadata as literals and a function cannot
204
+ * survive that.
205
+ */
206
+ onJobAbandoned?: (payload: unknown, info: { jobId: string | null; reason: string }) => void | Promise<void>
199
207
  handler: ModuleWorkerHandler
208
+ /** Opt-in flag exposing this queue as a user-facing scheduler target (issue #5213). */
209
+ schedulerSafe?: boolean
210
+ /** Creator features required beyond scheduler.jobs.manage when schedulerSafe is set. */
211
+ schedulerRequiredFeatures?: string[]
200
212
  }
201
213
 
202
214
  export type ModuleInfo = {
@@ -554,3 +566,31 @@ export function createLazyModuleWorker(
554
566
  return handler(job, ctx)
555
567
  }
556
568
  }
569
+
570
+ /**
571
+ * Resolves a worker's `metadata.onJobAbandoned` on first use.
572
+ *
573
+ * The generator serializes worker metadata as literals, so a function declared there cannot be
574
+ * emitted inline the way `concurrency` or `lockDuration` are. It is emitted as this thunk instead —
575
+ * the same lazy-import treatment the handler already gets — and only for workers whose metadata
576
+ * declares the callback, so no other queue acquires one (and with it, a sweep) by accident.
577
+ */
578
+ export function createLazyModuleWorkerAbandonHook(
579
+ loadModule: () => Promise<unknown>,
580
+ id: string
581
+ ): (payload: unknown, info: { jobId: string | null; reason: string }) => Promise<void> {
582
+ let hookPromise: Promise<((payload: unknown, info: { jobId: string | null; reason: string }) => unknown) | null> | null = null
583
+ return async (payload, info) => {
584
+ hookPromise ??= loadModule().then((loaded) => {
585
+ const metadata = (loaded as { metadata?: { onJobAbandoned?: unknown } } | null)?.metadata
586
+ return typeof metadata?.onJobAbandoned === 'function'
587
+ ? (metadata.onJobAbandoned as (payload: unknown, info: { jobId: string | null; reason: string }) => unknown)
588
+ : null
589
+ })
590
+ const hook = await hookPromise
591
+ if (!hook) {
592
+ throw new Error(`[registry] Worker "${id}" was registered with an abandoned-job hook but its metadata no longer declares one`)
593
+ }
594
+ await hook(payload, info)
595
+ }
596
+ }
@@ -26,6 +26,20 @@ export type ComponentOverride<TProps = unknown> = {
26
26
  propsSchema: ZodType<TProps>
27
27
  }
28
28
  | {
29
+ /**
30
+ * Higher-order component composed around the resolved component.
31
+ *
32
+ * The platform invokes it **at most once per `(wrapper, wrapped component)` pair**
33
+ * and caches the composed component for the lifetime of the registry, so that a
34
+ * wrapped subtree keeps a stable React identity instead of remounting on every
35
+ * override resolution.
36
+ *
37
+ * A wrapper MUST therefore be a pure function of `Original`: it may only read
38
+ * dynamic values — feature flags, locale, tenant configuration, the clock, request
39
+ * state — inside the render body of the component it returns, never at composition
40
+ * time. On the server the registry outlives a single request, so a value captured
41
+ * at composition time would be frozen across requests and tenants.
42
+ */
29
43
  wrapper: (Original: ComponentType<TProps>) => ComponentType<TProps>
30
44
  }
31
45
  | {