@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +4 -1
- package/dist/lib/auth/jwt.js +6 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/mfaPendingAccess.js +42 -0
- package/dist/lib/auth/mfaPendingAccess.js.map +7 -0
- package/dist/lib/auth/organizationAccess.js +7 -4
- package/dist/lib/auth/organizationAccess.js.map +2 -2
- package/dist/lib/auth/principal-service.js +1 -0
- package/dist/lib/auth/principal-service.js.map +7 -0
- package/dist/lib/auth/server.js +38 -7
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/commands/command-bus.js +6 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/crud/factory.js +24 -8
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +8 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/html/htmlToPlainText.js +16 -0
- package/dist/lib/html/htmlToPlainText.js.map +7 -0
- package/dist/lib/location/countries.js +12 -0
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/openapi/crud.js +4 -1
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/count-cap.js +11 -0
- package/dist/lib/query/count-cap.js.map +7 -0
- package/dist/lib/query/engine.js +270 -34
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/queue/dispatchOrigin.js +20 -0
- package/dist/lib/queue/dispatchOrigin.js.map +7 -0
- package/dist/lib/search/config.js +1 -0
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/entityAccess.js +44 -0
- package/dist/lib/search/entityAccess.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +69 -15
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +15 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/component-registry.js.map +2 -2
- package/package.json +10 -3
- package/src/lib/auth/__tests__/jwt.test.ts +13 -0
- package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +69 -0
- package/src/lib/auth/__tests__/organizationAccess.test.ts +36 -1
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
- package/src/lib/auth/__tests__/server.test.ts +104 -0
- package/src/lib/auth/jwt.ts +17 -0
- package/src/lib/auth/mfaPendingAccess.ts +70 -0
- package/src/lib/auth/organizationAccess.ts +11 -3
- package/src/lib/auth/principal-service.ts +110 -0
- package/src/lib/auth/server.ts +78 -8
- package/src/lib/commands/__tests__/command-bus.test.ts +31 -0
- package/src/lib/commands/command-bus.ts +8 -1
- package/src/lib/crud/__tests__/crud-factory.test.ts +165 -0
- package/src/lib/crud/factory.ts +33 -7
- package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
- package/src/lib/data/engine.ts +7 -1
- package/src/lib/html/__tests__/htmlToPlainText.test.ts +59 -0
- package/src/lib/html/htmlToPlainText.ts +17 -0
- package/src/lib/location/__tests__/countries.test.ts +15 -0
- package/src/lib/location/countries.ts +17 -0
- package/src/lib/openapi/crud.ts +3 -0
- package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
- package/src/lib/query/__tests__/count-cap.test.ts +41 -0
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +162 -15
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
- package/src/lib/query/__tests__/engine.test.ts +445 -7
- package/src/lib/query/count-cap.ts +19 -0
- package/src/lib/query/engine.ts +434 -54
- package/src/lib/query/types.ts +15 -0
- package/src/lib/queue/dispatchOrigin.ts +35 -0
- package/src/lib/search/config.ts +10 -0
- package/src/lib/search/entityAccess.ts +132 -0
- package/src/modules/events/__tests__/factory.test.ts +88 -0
- package/src/modules/events/factory.ts +111 -19
- package/src/modules/events/types.ts +17 -0
- package/src/modules/registry.ts +40 -0
- package/src/modules/widgets/component-registry.ts +14 -0
package/src/lib/query/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/search/config.ts
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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(
|
|
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 [...
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
// =============================================================================
|
package/src/modules/registry.ts
CHANGED
|
@@ -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
|
| {
|