@open-mercato/shared 0.6.8-develop.7099.1.7ba2771d06 → 0.7.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 +1 -4
- package/dist/lib/auth/jwt.js +0 -6
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/organizationAccess.js +4 -7
- package/dist/lib/auth/organizationAccess.js.map +2 -2
- package/dist/lib/auth/server.js +7 -38
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/commands/command-bus.js +1 -6
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/crud/factory.js +8 -24
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +2 -8
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/location/countries.js +0 -12
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/openapi/crud.js +1 -4
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/engine.js +34 -270
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/search/config.js +0 -1
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +15 -69
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +0 -15
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/component-registry.js.map +2 -2
- package/package.json +3 -10
- package/src/lib/auth/__tests__/jwt.test.ts +0 -13
- package/src/lib/auth/__tests__/organizationAccess.test.ts +1 -36
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +0 -324
- package/src/lib/auth/__tests__/server.test.ts +0 -104
- package/src/lib/auth/jwt.ts +0 -17
- package/src/lib/auth/organizationAccess.ts +3 -11
- package/src/lib/auth/server.ts +8 -78
- package/src/lib/commands/__tests__/command-bus.test.ts +0 -31
- package/src/lib/commands/command-bus.ts +1 -8
- package/src/lib/crud/__tests__/crud-factory.test.ts +0 -165
- package/src/lib/crud/factory.ts +7 -33
- package/src/lib/data/__tests__/engine.event-validation.test.ts +1 -9
- package/src/lib/data/engine.ts +1 -7
- package/src/lib/location/__tests__/countries.test.ts +0 -15
- package/src/lib/location/countries.ts +0 -17
- package/src/lib/openapi/crud.ts +0 -3
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +15 -162
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +1 -11
- package/src/lib/query/__tests__/engine.test.ts +7 -445
- package/src/lib/query/engine.ts +54 -434
- package/src/lib/query/types.ts +0 -15
- package/src/lib/search/config.ts +0 -10
- package/src/modules/events/factory.ts +19 -111
- package/src/modules/events/types.ts +0 -17
- package/src/modules/registry.ts +0 -40
- package/src/modules/widgets/component-registry.ts +0 -14
- package/dist/lib/auth/mfaPendingAccess.js +0 -42
- package/dist/lib/auth/mfaPendingAccess.js.map +0 -7
- package/dist/lib/auth/principal-service.js +0 -1
- package/dist/lib/auth/principal-service.js.map +0 -7
- package/dist/lib/html/htmlToPlainText.js +0 -16
- package/dist/lib/html/htmlToPlainText.js.map +0 -7
- package/dist/lib/query/count-cap.js +0 -11
- package/dist/lib/query/count-cap.js.map +0 -7
- package/dist/lib/queue/dispatchOrigin.js +0 -20
- package/dist/lib/queue/dispatchOrigin.js.map +0 -7
- package/dist/lib/search/entityAccess.js +0 -44
- package/dist/lib/search/entityAccess.js.map +0 -7
- package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +0 -69
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +0 -67
- package/src/lib/auth/mfaPendingAccess.ts +0 -70
- package/src/lib/auth/principal-service.ts +0 -110
- package/src/lib/html/__tests__/htmlToPlainText.test.ts +0 -59
- package/src/lib/html/htmlToPlainText.ts +0 -17
- package/src/lib/query/__tests__/count-cap-plan.test.ts +0 -240
- package/src/lib/query/__tests__/count-cap.test.ts +0 -41
- package/src/lib/query/count-cap.ts +0 -19
- package/src/lib/queue/dispatchOrigin.ts +0 -35
- package/src/lib/search/entityAccess.ts +0 -132
- package/src/modules/events/__tests__/factory.test.ts +0 -88
package/src/lib/query/types.ts
CHANGED
|
@@ -151,27 +151,12 @@ 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
|
-
*/
|
|
158
154
|
totalMatched: number
|
|
159
155
|
}
|
|
160
156
|
|
|
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
|
-
|
|
171
157
|
export type QueryResultMeta = {
|
|
172
158
|
partialIndexWarning?: PartialIndexWarning
|
|
173
159
|
encryptedSortRowCapWarning?: EncryptedSortRowCapWarning
|
|
174
|
-
listCountCapWarning?: ListCountCapWarning
|
|
175
160
|
}
|
|
176
161
|
|
|
177
162
|
export type QueryResult<T = any> = {
|
package/src/lib/search/config.ts
CHANGED
|
@@ -8,15 +8,6 @@ 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
|
|
20
11
|
blocklistedFields: string[]
|
|
21
12
|
entityBlocklistedFields?: Record<string, string[]>
|
|
22
13
|
maxFieldChars?: number
|
|
@@ -121,7 +112,6 @@ export function resolveSearchConfig(): SearchConfig {
|
|
|
121
112
|
enablePartials: parseBoolean(process.env.OM_SEARCH_ENABLE_PARTIAL, true),
|
|
122
113
|
hashAlgorithm: parseHashAlgorithm(process.env.OM_SEARCH_HASH_ALGO),
|
|
123
114
|
storeRawTokens: parseBoolean(process.env.OM_SEARCH_STORE_RAW_TOKENS, false),
|
|
124
|
-
useIlikeForNonEncryptedFields: parseBoolean(process.env.OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS, false),
|
|
125
115
|
blocklistedFields: blocklist.global,
|
|
126
116
|
entityBlocklistedFields: blocklist.byEntity,
|
|
127
117
|
maxFieldChars: parseNumber(process.env.OM_SEARCH_MAX_FIELD_CHARS, DEFAULT_SEARCH_MAX_FIELD_CHARS, 0),
|
|
@@ -65,54 +65,17 @@ export function getGlobalEventBus(): GlobalEventBus | null {
|
|
|
65
65
|
// Event Registry for Validation
|
|
66
66
|
// =============================================================================
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
}
|
|
68
|
+
// Global set of all declared event IDs for runtime validation
|
|
69
|
+
const allDeclaredEventIds = new Set<string>()
|
|
89
70
|
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
}
|
|
71
|
+
// Global registry of all declared events with their full definitions
|
|
72
|
+
const allDeclaredEvents: EventDefinition[] = []
|
|
103
73
|
|
|
104
74
|
function addDeclaredEvent(event: EventDefinition): void {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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)
|
|
116
79
|
}
|
|
117
80
|
}
|
|
118
81
|
|
|
@@ -121,7 +84,7 @@ function addDeclaredEvent(event: EventDefinition): void {
|
|
|
121
84
|
* Used for runtime validation to ensure only declared events are emitted.
|
|
122
85
|
*/
|
|
123
86
|
export function isEventDeclared(eventId: string): boolean {
|
|
124
|
-
return
|
|
87
|
+
return allDeclaredEventIds.has(eventId)
|
|
125
88
|
}
|
|
126
89
|
|
|
127
90
|
/**
|
|
@@ -129,7 +92,7 @@ export function isEventDeclared(eventId: string): boolean {
|
|
|
129
92
|
* Useful for debugging and introspection.
|
|
130
93
|
*/
|
|
131
94
|
export function getAllDeclaredEventIds(): string[] {
|
|
132
|
-
return Array.from(
|
|
95
|
+
return Array.from(allDeclaredEventIds)
|
|
133
96
|
}
|
|
134
97
|
|
|
135
98
|
/**
|
|
@@ -137,7 +100,7 @@ export function getAllDeclaredEventIds(): string[] {
|
|
|
137
100
|
* Used by the API to return available events for workflow triggers.
|
|
138
101
|
*/
|
|
139
102
|
export function getDeclaredEvents(): EventDefinition[] {
|
|
140
|
-
return [...
|
|
103
|
+
return [...allDeclaredEvents]
|
|
141
104
|
}
|
|
142
105
|
|
|
143
106
|
/**
|
|
@@ -145,52 +108,16 @@ export function getDeclaredEvents(): EventDefinition[] {
|
|
|
145
108
|
* Used by the SSE endpoint to filter events for the DOM Event Bridge.
|
|
146
109
|
*/
|
|
147
110
|
export function isBroadcastEvent(eventId: string): boolean {
|
|
148
|
-
const event =
|
|
111
|
+
const event = allDeclaredEvents.find(e => e.id === eventId)
|
|
149
112
|
return event?.clientBroadcast === true
|
|
150
113
|
}
|
|
151
114
|
|
|
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
|
-
|
|
188
115
|
/**
|
|
189
116
|
* Check if an event has portalBroadcast enabled.
|
|
190
117
|
* Used by the portal SSE endpoint to filter events for the Portal Event Bridge.
|
|
191
118
|
*/
|
|
192
119
|
export function isPortalBroadcastEvent(eventId: string): boolean {
|
|
193
|
-
const event =
|
|
120
|
+
const event = allDeclaredEvents.find(e => e.id === eventId)
|
|
194
121
|
return event?.portalBroadcast === true
|
|
195
122
|
}
|
|
196
123
|
|
|
@@ -198,16 +125,17 @@ export function isPortalBroadcastEvent(eventId: string): boolean {
|
|
|
198
125
|
// Bootstrap Registration (similar to searchModuleConfigs pattern)
|
|
199
126
|
// =============================================================================
|
|
200
127
|
|
|
128
|
+
let _registeredEventConfigs: EventModuleConfig[] | null = null
|
|
129
|
+
|
|
201
130
|
/**
|
|
202
131
|
* Register event module configurations globally.
|
|
203
132
|
* Called during app bootstrap with configs from events.generated.ts.
|
|
204
133
|
*/
|
|
205
134
|
export function registerEventModuleConfigs(configs: EventModuleConfig[]): void {
|
|
206
|
-
|
|
207
|
-
if (state.registeredEventConfigs !== null && process.env.NODE_ENV === 'development') {
|
|
135
|
+
if (_registeredEventConfigs !== null && process.env.NODE_ENV === 'development') {
|
|
208
136
|
logger.debug('Event module configs re-registered (this may occur during HMR)')
|
|
209
137
|
}
|
|
210
|
-
|
|
138
|
+
_registeredEventConfigs = configs
|
|
211
139
|
for (const config of configs) {
|
|
212
140
|
for (const event of config.events) {
|
|
213
141
|
addDeclaredEvent(event)
|
|
@@ -220,7 +148,7 @@ export function registerEventModuleConfigs(configs: EventModuleConfig[]): void {
|
|
|
220
148
|
* Returns empty array if not registered.
|
|
221
149
|
*/
|
|
222
150
|
export function getEventModuleConfigs(): EventModuleConfig[] {
|
|
223
|
-
return
|
|
151
|
+
return _registeredEventConfigs ?? []
|
|
224
152
|
}
|
|
225
153
|
|
|
226
154
|
// =============================================================================
|
|
@@ -311,27 +239,7 @@ export function createModuleEvents<
|
|
|
311
239
|
return
|
|
312
240
|
}
|
|
313
241
|
|
|
314
|
-
|
|
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)
|
|
242
|
+
await eventBus.emit(eventId, payload, emitOptions)
|
|
335
243
|
}
|
|
336
244
|
|
|
337
245
|
return {
|
|
@@ -34,8 +34,6 @@ 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
|
|
39
37
|
/** When true, this event is bridged to the customer portal via SSE (Portal Event Bridge). Default: false */
|
|
40
38
|
portalBroadcast?: boolean
|
|
41
39
|
}
|
|
@@ -68,21 +66,6 @@ export interface EmitOptions {
|
|
|
68
66
|
tenantId?: string | null
|
|
69
67
|
/** Trusted organization scope forwarded to subscribers separately from the payload */
|
|
70
68
|
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
|
|
86
69
|
}
|
|
87
70
|
|
|
88
71
|
// =============================================================================
|
package/src/modules/registry.ts
CHANGED
|
@@ -196,19 +196,7 @@ 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>
|
|
207
199
|
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[]
|
|
212
200
|
}
|
|
213
201
|
|
|
214
202
|
export type ModuleInfo = {
|
|
@@ -566,31 +554,3 @@ export function createLazyModuleWorker(
|
|
|
566
554
|
return handler(job, ctx)
|
|
567
555
|
}
|
|
568
556
|
}
|
|
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,20 +26,6 @@ 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
|
-
*/
|
|
43
29
|
wrapper: (Original: ComponentType<TProps>) => ComponentType<TProps>
|
|
44
30
|
}
|
|
45
31
|
| {
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
const registeredMethodsByPath = /* @__PURE__ */ new Map();
|
|
2
|
-
function normalizePath(path) {
|
|
3
|
-
if (typeof path !== "string") return null;
|
|
4
|
-
const trimmed = path.trim();
|
|
5
|
-
if (!trimmed || !trimmed.startsWith("/")) return null;
|
|
6
|
-
return (trimmed.length > 1 ? trimmed.replace(/\/+$/, "") : trimmed).toLowerCase() || "/";
|
|
7
|
-
}
|
|
8
|
-
function normalizeMethods(methods) {
|
|
9
|
-
if (!Array.isArray(methods)) return [];
|
|
10
|
-
const normalized = methods.filter((method) => typeof method === "string" && method.trim().length > 0).map((method) => method.trim().toUpperCase());
|
|
11
|
-
return Array.from(new Set(normalized)).sort((first, second) => first < second ? -1 : first > second ? 1 : 0);
|
|
12
|
-
}
|
|
13
|
-
function registerMfaPendingAccessRoutes(routes) {
|
|
14
|
-
for (const route of Array.isArray(routes) ? routes : []) {
|
|
15
|
-
const path = normalizePath(route?.path ?? "");
|
|
16
|
-
const methods = normalizeMethods(route?.methods);
|
|
17
|
-
if (!path || !methods.length) continue;
|
|
18
|
-
const existing = registeredMethodsByPath.get(path);
|
|
19
|
-
if (!existing) {
|
|
20
|
-
registeredMethodsByPath.set(path, new Set(methods));
|
|
21
|
-
continue;
|
|
22
|
-
}
|
|
23
|
-
for (const method of methods) existing.add(method);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
function isMfaPendingAccessAllowed(method, pathname) {
|
|
27
|
-
const path = normalizePath(typeof pathname === "string" ? pathname : "");
|
|
28
|
-
if (!path) return false;
|
|
29
|
-
if (typeof method !== "string" || method.trim().length === 0) return false;
|
|
30
|
-
const methods = registeredMethodsByPath.get(path);
|
|
31
|
-
if (!methods) return false;
|
|
32
|
-
return methods.has(method.trim().toUpperCase());
|
|
33
|
-
}
|
|
34
|
-
function listMfaPendingAccessRoutes() {
|
|
35
|
-
return Array.from(registeredMethodsByPath.entries()).map(([path, methods]) => ({ path, methods: Array.from(methods).sort((first, second) => first < second ? -1 : first > second ? 1 : 0) })).sort((first, second) => first.path < second.path ? -1 : first.path > second.path ? 1 : 0);
|
|
36
|
-
}
|
|
37
|
-
export {
|
|
38
|
-
isMfaPendingAccessAllowed,
|
|
39
|
-
listMfaPendingAccessRoutes,
|
|
40
|
-
registerMfaPendingAccessRoutes
|
|
41
|
-
};
|
|
42
|
-
//# sourceMappingURL=mfaPendingAccess.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/lib/auth/mfaPendingAccess.ts"],
|
|
4
|
-
"sourcesContent": ["export type MfaPendingAccessRoute = {\n path: string\n methods: string[]\n}\n\nconst registeredMethodsByPath = new Map<string, Set<string>>()\n\nfunction normalizePath(path: string): string | null {\n if (typeof path !== 'string') return null\n const trimmed = path.trim()\n if (!trimmed || !trimmed.startsWith('/')) return null\n return (trimmed.length > 1 ? trimmed.replace(/\\/+$/, '') : trimmed).toLowerCase() || '/'\n}\n\nfunction normalizeMethods(methods: unknown): string[] {\n if (!Array.isArray(methods)) return []\n const normalized = methods\n .filter((method): method is string => typeof method === 'string' && method.trim().length > 0)\n .map((method) => method.trim().toUpperCase())\n return Array.from(new Set(normalized)).sort((first, second) => (first < second ? -1 : first > second ? 1 : 0))\n}\n\n/**\n * Register routes that may accept an MFA-pending staff token. The registry starts empty and\n * stays generic: every module that completes an MFA challenge on its own endpoints registers\n * them here during its bootstrap \u2014 the canonical `/api/security/mfa/*` completion routes are\n * registered by the enterprise `security` module (`lib/mfaCompletionRoutes.ts`), third-party\n * MFA implementations register theirs the same way. Registration is additive: methods merge\n * into any existing entry for the same path and are never removed.\n *\n * Contract defined in `.ai/specs/enterprise/implemented/SPEC-ENT-007-2026-03-06-auth-login-interceptors-extension.md`\n * (\u00A7 Amendment 2026-08-21 \u2014 central MFA-pending token gate).\n */\nexport function registerMfaPendingAccessRoutes(routes: MfaPendingAccessRoute[]): void {\n for (const route of Array.isArray(routes) ? routes : []) {\n const path = normalizePath(route?.path ?? '')\n const methods = normalizeMethods(route?.methods)\n if (!path || !methods.length) continue\n const existing = registeredMethodsByPath.get(path)\n if (!existing) {\n registeredMethodsByPath.set(path, new Set(methods))\n continue\n }\n for (const method of methods) existing.add(method)\n }\n}\n\n/**\n * Fail-closed check deciding whether an MFA-pending staff token may be resolved for this exact\n * method + path pair. Only explicitly registered completion routes pass; every unregistered\n * request is denied.\n */\nexport function isMfaPendingAccessAllowed(\n method: string | null | undefined,\n pathname: string | null | undefined,\n): boolean {\n const path = normalizePath(typeof pathname === 'string' ? pathname : '')\n if (!path) return false\n if (typeof method !== 'string' || method.trim().length === 0) return false\n const methods = registeredMethodsByPath.get(path)\n if (!methods) return false\n return methods.has(method.trim().toUpperCase())\n}\n\n/** Test/ops helper: current snapshot of the pending-access registry. */\nexport function listMfaPendingAccessRoutes(): ReadonlyArray<Readonly<MfaPendingAccessRoute>> {\n return Array.from(registeredMethodsByPath.entries())\n .map(([path, methods]) => ({ path, methods: Array.from(methods).sort((first, second) => (first < second ? -1 : first > second ? 1 : 0)) }))\n .sort((first, second) => (first.path < second.path ? -1 : first.path > second.path ? 1 : 0))\n}\n"],
|
|
5
|
-
"mappings": "AAKA,MAAM,0BAA0B,oBAAI,IAAyB;AAE7D,SAAS,cAAc,MAA6B;AAClD,MAAI,OAAO,SAAS,SAAU,QAAO;AACrC,QAAM,UAAU,KAAK,KAAK;AAC1B,MAAI,CAAC,WAAW,CAAC,QAAQ,WAAW,GAAG,EAAG,QAAO;AACjD,UAAQ,QAAQ,SAAS,IAAI,QAAQ,QAAQ,QAAQ,EAAE,IAAI,SAAS,YAAY,KAAK;AACvF;AAEA,SAAS,iBAAiB,SAA4B;AACpD,MAAI,CAAC,MAAM,QAAQ,OAAO,EAAG,QAAO,CAAC;AACrC,QAAM,aAAa,QAChB,OAAO,CAAC,WAA6B,OAAO,WAAW,YAAY,OAAO,KAAK,EAAE,SAAS,CAAC,EAC3F,IAAI,CAAC,WAAW,OAAO,KAAK,EAAE,YAAY,CAAC;AAC9C,SAAO,MAAM,KAAK,IAAI,IAAI,UAAU,CAAC,EAAE,KAAK,CAAC,OAAO,WAAY,QAAQ,SAAS,KAAK,QAAQ,SAAS,IAAI,CAAE;AAC/G;AAaO,SAAS,+BAA+B,QAAuC;AACpF,aAAW,SAAS,MAAM,QAAQ,MAAM,IAAI,SAAS,CAAC,GAAG;AACvD,UAAM,OAAO,cAAc,OAAO,QAAQ,EAAE;AAC5C,UAAM,UAAU,iBAAiB,OAAO,OAAO;AAC/C,QAAI,CAAC,QAAQ,CAAC,QAAQ,OAAQ;AAC9B,UAAM,WAAW,wBAAwB,IAAI,IAAI;AACjD,QAAI,CAAC,UAAU;AACb,8BAAwB,IAAI,MAAM,IAAI,IAAI,OAAO,CAAC;AAClD;AAAA,IACF;AACA,eAAW,UAAU,QAAS,UAAS,IAAI,MAAM;AAAA,EACnD;AACF;AAOO,SAAS,0BACd,QACA,UACS;AACT,QAAM,OAAO,cAAc,OAAO,aAAa,WAAW,WAAW,EAAE;AACvE,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI,OAAO,WAAW,YAAY,OAAO,KAAK,EAAE,WAAW,EAAG,QAAO;AACrE,QAAM,UAAU,wBAAwB,IAAI,IAAI;AAChD,MAAI,CAAC,QAAS,QAAO;AACrB,SAAO,QAAQ,IAAI,OAAO,KAAK,EAAE,YAAY,CAAC;AAChD;AAGO,SAAS,6BAA6E;AAC3F,SAAO,MAAM,KAAK,wBAAwB,QAAQ,CAAC,EAChD,IAAI,CAAC,CAAC,MAAM,OAAO,OAAO,EAAE,MAAM,SAAS,MAAM,KAAK,OAAO,EAAE,KAAK,CAAC,OAAO,WAAY,QAAQ,SAAS,KAAK,QAAQ,SAAS,IAAI,CAAE,EAAE,EAAE,EACzI,KAAK,CAAC,OAAO,WAAY,MAAM,OAAO,OAAO,OAAO,KAAK,MAAM,OAAO,OAAO,OAAO,IAAI,CAAE;AAC/F;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
//# sourceMappingURL=principal-service.js.map
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { convert } from "html-to-text";
|
|
2
|
-
function htmlToPlainText(html) {
|
|
3
|
-
if (!html) return "";
|
|
4
|
-
return convert(html, {
|
|
5
|
-
wordwrap: false,
|
|
6
|
-
preserveNewlines: true,
|
|
7
|
-
selectors: [
|
|
8
|
-
{ selector: "script", format: "skip" },
|
|
9
|
-
{ selector: "style", format: "skip" }
|
|
10
|
-
]
|
|
11
|
-
}).replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
12
|
-
}
|
|
13
|
-
export {
|
|
14
|
-
htmlToPlainText
|
|
15
|
-
};
|
|
16
|
-
//# sourceMappingURL=htmlToPlainText.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/lib/html/htmlToPlainText.ts"],
|
|
4
|
-
"sourcesContent": ["import { convert } from 'html-to-text'\n\nexport function htmlToPlainText(html: string): string {\n if (!html) return ''\n return convert(html, {\n wordwrap: false,\n preserveNewlines: true,\n selectors: [\n { selector: 'script', format: 'skip' },\n { selector: 'style', format: 'skip' },\n ],\n })\n .replace(/\\r\\n/g, '\\n')\n .replace(/\\r/g, '\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim()\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,eAAe;AAEjB,SAAS,gBAAgB,MAAsB;AACpD,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,QAAQ,MAAM;AAAA,IACnB,UAAU;AAAA,IACV,kBAAkB;AAAA,IAClB,WAAW;AAAA,MACT,EAAE,UAAU,UAAU,QAAQ,OAAO;AAAA,MACrC,EAAE,UAAU,SAAS,QAAQ,OAAO;AAAA,IACtC;AAAA,EACF,CAAC,EACE,QAAQ,SAAS,IAAI,EACrB,QAAQ,OAAO,IAAI,EACnB,QAAQ,WAAW,MAAM,EACzB,KAAK;AACV;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { parseNumberWithDefault } from "../number.js";
|
|
2
|
-
const DEFAULT_LIST_COUNT_CAP = 1e4;
|
|
3
|
-
function resolveListCountCap() {
|
|
4
|
-
const parsed = parseNumberWithDefault(process.env.OM_LIST_COUNT_CAP, DEFAULT_LIST_COUNT_CAP, { integer: true });
|
|
5
|
-
return parsed <= 0 ? null : parsed;
|
|
6
|
-
}
|
|
7
|
-
export {
|
|
8
|
-
DEFAULT_LIST_COUNT_CAP,
|
|
9
|
-
resolveListCountCap
|
|
10
|
-
};
|
|
11
|
-
//# sourceMappingURL=count-cap.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/lib/query/count-cap.ts"],
|
|
4
|
-
"sourcesContent": ["import { parseNumberWithDefault } from '../number'\n\nexport const DEFAULT_LIST_COUNT_CAP = 10_000\n\n/**\n * Cap on how many matching rows a list COUNT may visit before reporting\n * `total: cap` with `meta.listCountCapWarning` (surfaced to clients as\n * `totalIsCapped`). Returns the cap as a number, or `null` when capping is\n * disabled.\n *\n * Resolution of `OM_LIST_COUNT_CAP`:\n * - unset / blank / unparseable \u2192 `DEFAULT_LIST_COUNT_CAP` (the cap is on by\n * default, and bad input must not silently disable it)\n * - `0` (or negative) \u2192 `null` \u2014 capping disabled, exact counts everywhere\n */\nexport function resolveListCountCap(): number | null {\n const parsed = parseNumberWithDefault(process.env.OM_LIST_COUNT_CAP, DEFAULT_LIST_COUNT_CAP, { integer: true })\n return parsed <= 0 ? null : parsed\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,8BAA8B;AAEhC,MAAM,yBAAyB;AAa/B,SAAS,sBAAqC;AACnD,QAAM,SAAS,uBAAuB,QAAQ,IAAI,mBAAmB,wBAAwB,EAAE,SAAS,KAAK,CAAC;AAC9G,SAAO,UAAU,IAAI,OAAO;AAC9B;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
const QUEUE_JOB_ORIGIN_KEY = "_jobOrigin";
|
|
2
|
-
function markQueueJobOrigin(payload, origin) {
|
|
3
|
-
return { ...payload, [QUEUE_JOB_ORIGIN_KEY]: origin };
|
|
4
|
-
}
|
|
5
|
-
function readQueueJobOrigin(payload) {
|
|
6
|
-
if (!payload || typeof payload !== "object") return null;
|
|
7
|
-
const value = payload[QUEUE_JOB_ORIGIN_KEY];
|
|
8
|
-
if (value === "inbound-webhook" || value === "scheduler") return value;
|
|
9
|
-
return null;
|
|
10
|
-
}
|
|
11
|
-
function isTrustedWebhookDispatch(payload) {
|
|
12
|
-
return readQueueJobOrigin(payload) === "inbound-webhook";
|
|
13
|
-
}
|
|
14
|
-
export {
|
|
15
|
-
QUEUE_JOB_ORIGIN_KEY,
|
|
16
|
-
isTrustedWebhookDispatch,
|
|
17
|
-
markQueueJobOrigin,
|
|
18
|
-
readQueueJobOrigin
|
|
19
|
-
};
|
|
20
|
-
//# sourceMappingURL=dispatchOrigin.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/lib/queue/dispatchOrigin.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Trusted dispatch-origin markers for queue jobs.\n *\n * Jobs that drive privileged side effects (payment webhook processors and\n * similar sinks) MUST only execute payloads enqueued by trusted infrastructure\n * code paths. The scheduler dispatch layer and inbound webhook routes mark the\n * payloads they enqueue; sensitive workers verify the marker before doing work.\n *\n * The scheduler payload sanitizer strips every caller-supplied `_`-prefixed\n * key from scheduled target payloads, so a marker cannot be forged through the\n * scheduler job API. Direct queue/Redis write access is operator-level access\n * and outside this threat model.\n */\n\nexport const QUEUE_JOB_ORIGIN_KEY = '_jobOrigin'\n\nexport type QueueJobOrigin = 'inbound-webhook' | 'scheduler'\n\nexport function markQueueJobOrigin<T extends Record<string, unknown>>(\n payload: T,\n origin: QueueJobOrigin,\n): T {\n return { ...payload, [QUEUE_JOB_ORIGIN_KEY]: origin }\n}\n\nexport function readQueueJobOrigin(payload: unknown): QueueJobOrigin | null {\n if (!payload || typeof payload !== 'object') return null\n const value = (payload as Record<string, unknown>)[QUEUE_JOB_ORIGIN_KEY]\n if (value === 'inbound-webhook' || value === 'scheduler') return value\n return null\n}\n\nexport function isTrustedWebhookDispatch(payload: unknown): boolean {\n return readQueueJobOrigin(payload) === 'inbound-webhook'\n}\n"],
|
|
5
|
-
"mappings": "AAcO,MAAM,uBAAuB;AAI7B,SAAS,mBACd,SACA,QACG;AACH,SAAO,EAAE,GAAG,SAAS,CAAC,oBAAoB,GAAG,OAAO;AACtD;AAEO,SAAS,mBAAmB,SAAyC;AAC1E,MAAI,CAAC,WAAW,OAAO,YAAY,SAAU,QAAO;AACpD,QAAM,QAAS,QAAoC,oBAAoB;AACvE,MAAI,UAAU,qBAAqB,UAAU,YAAa,QAAO;AACjE,SAAO;AACT;AAEO,SAAS,yBAAyB,SAA2B;AAClE,SAAO,mBAAmB,OAAO,MAAM;AACzC;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { authorizeFeatures } from "../../security/featurePolicy.js";
|
|
2
|
-
function canReadSearchEntity(entityId, lookup, subject, options = {}) {
|
|
3
|
-
if (subject.isSuperAdmin) return true;
|
|
4
|
-
const config = lookup.getEntityConfig(entityId);
|
|
5
|
-
if (!config) {
|
|
6
|
-
options.onDeny?.(entityId, "unconfigured");
|
|
7
|
-
return false;
|
|
8
|
-
}
|
|
9
|
-
const required = config.aclFeatures;
|
|
10
|
-
if (!required || required.length === 0) {
|
|
11
|
-
options.onDeny?.(entityId, "no-acl-features");
|
|
12
|
-
return false;
|
|
13
|
-
}
|
|
14
|
-
const allowed = authorizeFeatures(required, {
|
|
15
|
-
grantedFeatures: subject.grantedFeatures,
|
|
16
|
-
unrestricted: false
|
|
17
|
-
});
|
|
18
|
-
if (!allowed) options.onDeny?.(entityId, "insufficient-features");
|
|
19
|
-
return allowed;
|
|
20
|
-
}
|
|
21
|
-
function resolveReadableEntityTypes(lookup, subject, requestedEntityTypes) {
|
|
22
|
-
if (subject.isSuperAdmin) return requestedEntityTypes;
|
|
23
|
-
const readable = lookup.getAllEntityConfigs().filter((config) => config.enabled !== false).map((config) => config.entityId).filter((entityId) => canReadSearchEntity(entityId, lookup, subject));
|
|
24
|
-
if (!requestedEntityTypes) return readable;
|
|
25
|
-
const requested = new Set(requestedEntityTypes);
|
|
26
|
-
return readable.filter((entityId) => requested.has(entityId));
|
|
27
|
-
}
|
|
28
|
-
function filterSearchResultsByEntityAccess(results, lookup, subject, options = {}) {
|
|
29
|
-
if (subject.isSuperAdmin) return [...results];
|
|
30
|
-
const decisions = /* @__PURE__ */ new Map();
|
|
31
|
-
return results.filter((result) => {
|
|
32
|
-
const cached = decisions.get(result.entityId);
|
|
33
|
-
if (cached !== void 0) return cached;
|
|
34
|
-
const allowed = canReadSearchEntity(result.entityId, lookup, subject, options);
|
|
35
|
-
decisions.set(result.entityId, allowed);
|
|
36
|
-
return allowed;
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
export {
|
|
40
|
-
canReadSearchEntity,
|
|
41
|
-
filterSearchResultsByEntityAccess,
|
|
42
|
-
resolveReadableEntityTypes
|
|
43
|
-
};
|
|
44
|
-
//# sourceMappingURL=entityAccess.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/lib/search/entityAccess.ts"],
|
|
4
|
-
"sourcesContent": ["import type { SearchEntityConfig } from '../../modules/search'\nimport { authorizeFeatures } from '../../security/featurePolicy'\n\n/**\n * Minimal shape of the `searchIndexer` DI service consumed by per-entity ACL\n * resolution. Kept structural so callers and tests can pass a plain object\n * instead of constructing a full `SearchIndexer`.\n */\nexport type SearchEntityConfigLookup = {\n getEntityConfig: (entityId: string) => SearchEntityConfig | undefined\n getAllEntityConfigs: () => SearchEntityConfig[]\n}\n\nexport type SearchEntityAccessSubject = {\n grantedFeatures: readonly string[]\n isSuperAdmin?: boolean\n}\n\nexport type SearchEntityDenyReason =\n /** No module declares this entity in a `search.ts` config. */\n | 'unconfigured'\n /** The entity is configured for search but declares no `aclFeatures`. */\n | 'no-acl-features'\n /** The caller does not hold the entity's declared view feature(s). */\n | 'insufficient-features'\n\nexport type SearchEntityAccessOptions = {\n /**\n * Called once per denied entity type. Exists so a silent drop is diagnosable:\n * results disappearing because a module forgot to declare `aclFeatures` looks\n * identical, from the palette, to results that simply did not match.\n */\n onDeny?: (entityId: string, reason: SearchEntityDenyReason) => void\n}\n\n/**\n * Decide whether a caller may see results for one entity type.\n *\n * The single `search.global` gate on the palette only says \"this user may use\n * global search\"; it says nothing about which records they may read. Each entity\n * declares the owning module's view feature(s) in `aclFeatures`, and those are\n * what actually authorize the read \u2014 the same rule the `search_get` /\n * `search_aggregate` AI tools already apply.\n *\n * Fails closed: an entity that is not registered for search, or that declares no\n * `aclFeatures`, is never exposed to a non-superadmin caller.\n */\nexport function canReadSearchEntity(\n entityId: string,\n lookup: SearchEntityConfigLookup,\n subject: SearchEntityAccessSubject,\n options: SearchEntityAccessOptions = {},\n): boolean {\n if (subject.isSuperAdmin) return true\n\n const config = lookup.getEntityConfig(entityId)\n if (!config) {\n options.onDeny?.(entityId, 'unconfigured')\n return false\n }\n\n const required = config.aclFeatures\n if (!required || required.length === 0) {\n options.onDeny?.(entityId, 'no-acl-features')\n return false\n }\n\n const allowed = authorizeFeatures(required, {\n grantedFeatures: subject.grantedFeatures,\n unrestricted: false,\n })\n if (!allowed) options.onDeny?.(entityId, 'insufficient-features')\n return allowed\n}\n\n/**\n * The entity types this caller may read, narrowed to `requestedEntityTypes` when\n * the caller asked for specific ones.\n *\n * Restricting the query up front is what keeps `limit` meaningful. Filtering only\n * after the search would spend the whole result budget on records the caller\n * cannot see: an employee granted just `customers.people.view` would get the top\n * 50 hits across every entity type, then watch most of them be dropped, and the\n * palette would look empty even with hundreds of matching people behind it.\n *\n * Returns `undefined` when no restriction applies (superadmin with no explicit\n * request), and an empty array when nothing is readable \u2014 callers should\n * short-circuit on that rather than pass it down as \"no filter\".\n */\nexport function resolveReadableEntityTypes(\n lookup: SearchEntityConfigLookup,\n subject: SearchEntityAccessSubject,\n requestedEntityTypes?: string[],\n): string[] | undefined {\n if (subject.isSuperAdmin) return requestedEntityTypes\n\n const readable = lookup\n .getAllEntityConfigs()\n .filter((config) => config.enabled !== false)\n .map((config) => config.entityId)\n .filter((entityId) => canReadSearchEntity(entityId, lookup, subject))\n\n if (!requestedEntityTypes) return readable\n const requested = new Set(requestedEntityTypes)\n return readable.filter((entityId) => requested.has(entityId))\n}\n\n/**\n * Drop the results whose entity type the caller is not allowed to read.\n *\n * Filtering happens server-side so an under-privileged caller never receives the\n * presenter title, subtitle or deep link of a record they cannot open. Decisions\n * are memoized per entity type because a single response commonly mixes dozens of\n * results across a handful of types.\n */\nexport function filterSearchResultsByEntityAccess<T extends { entityId: string }>(\n results: readonly T[],\n lookup: SearchEntityConfigLookup,\n subject: SearchEntityAccessSubject,\n options: SearchEntityAccessOptions = {},\n): T[] {\n if (subject.isSuperAdmin) return [...results]\n\n const decisions = new Map<string, boolean>()\n return results.filter((result) => {\n const cached = decisions.get(result.entityId)\n if (cached !== undefined) return cached\n const allowed = canReadSearchEntity(result.entityId, lookup, subject, options)\n decisions.set(result.entityId, allowed)\n return allowed\n })\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,yBAAyB;AA8C3B,SAAS,oBACd,UACA,QACA,SACA,UAAqC,CAAC,GAC7B;AACT,MAAI,QAAQ,aAAc,QAAO;AAEjC,QAAM,SAAS,OAAO,gBAAgB,QAAQ;AAC9C,MAAI,CAAC,QAAQ;AACX,YAAQ,SAAS,UAAU,cAAc;AACzC,WAAO;AAAA,EACT;AAEA,QAAM,WAAW,OAAO;AACxB,MAAI,CAAC,YAAY,SAAS,WAAW,GAAG;AACtC,YAAQ,SAAS,UAAU,iBAAiB;AAC5C,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,kBAAkB,UAAU;AAAA,IAC1C,iBAAiB,QAAQ;AAAA,IACzB,cAAc;AAAA,EAChB,CAAC;AACD,MAAI,CAAC,QAAS,SAAQ,SAAS,UAAU,uBAAuB;AAChE,SAAO;AACT;AAgBO,SAAS,2BACd,QACA,SACA,sBACsB;AACtB,MAAI,QAAQ,aAAc,QAAO;AAEjC,QAAM,WAAW,OACd,oBAAoB,EACpB,OAAO,CAAC,WAAW,OAAO,YAAY,KAAK,EAC3C,IAAI,CAAC,WAAW,OAAO,QAAQ,EAC/B,OAAO,CAAC,aAAa,oBAAoB,UAAU,QAAQ,OAAO,CAAC;AAEtE,MAAI,CAAC,qBAAsB,QAAO;AAClC,QAAM,YAAY,IAAI,IAAI,oBAAoB;AAC9C,SAAO,SAAS,OAAO,CAAC,aAAa,UAAU,IAAI,QAAQ,CAAC;AAC9D;AAUO,SAAS,kCACd,SACA,QACA,SACA,UAAqC,CAAC,GACjC;AACL,MAAI,QAAQ,aAAc,QAAO,CAAC,GAAG,OAAO;AAE5C,QAAM,YAAY,oBAAI,IAAqB;AAC3C,SAAO,QAAQ,OAAO,CAAC,WAAW;AAChC,UAAM,SAAS,UAAU,IAAI,OAAO,QAAQ;AAC5C,QAAI,WAAW,OAAW,QAAO;AACjC,UAAM,UAAU,oBAAoB,OAAO,UAAU,QAAQ,SAAS,OAAO;AAC7E,cAAU,IAAI,OAAO,UAAU,OAAO;AACtC,WAAO;AAAA,EACT,CAAC;AACH;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
isMfaPendingAccessAllowed,
|
|
3
|
-
listMfaPendingAccessRoutes,
|
|
4
|
-
registerMfaPendingAccessRoutes,
|
|
5
|
-
} from '../mfaPendingAccess'
|
|
6
|
-
|
|
7
|
-
describe('mfaPendingAccess', () => {
|
|
8
|
-
it('ships an empty generic registry and denies unknown routes by default', () => {
|
|
9
|
-
const registeredPaths = listMfaPendingAccessRoutes().map((route) => route.path)
|
|
10
|
-
expect(registeredPaths).not.toContain('/api/acme-mfa/complete')
|
|
11
|
-
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/complete')).toBe(false)
|
|
12
|
-
expect(isMfaPendingAccessAllowed('GET', '/api/customers/people')).toBe(false)
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
it('allows registered completion routes for their exact methods only', () => {
|
|
16
|
-
registerMfaPendingAccessRoutes([{ path: '/api/acme-mfa/t2/verify', methods: ['POST'] }])
|
|
17
|
-
|
|
18
|
-
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t2/verify')).toBe(true)
|
|
19
|
-
expect(isMfaPendingAccessAllowed('post', '/api/acme-mfa/t2/verify')).toBe(true)
|
|
20
|
-
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t2/verify/')).toBe(true)
|
|
21
|
-
expect(isMfaPendingAccessAllowed('GET', '/api/acme-mfa/t2/verify')).toBe(false)
|
|
22
|
-
expect(isMfaPendingAccessAllowed('PUT', '/api/acme-mfa/t2/verify')).toBe(false)
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('rejects unregistered paths and near-misses', () => {
|
|
26
|
-
registerMfaPendingAccessRoutes([{ path: '/api/acme-mfa/t3/recovery', methods: ['POST'] }])
|
|
27
|
-
|
|
28
|
-
expect(isMfaPendingAccessAllowed('POST', '/api/customers/people')).toBe(false)
|
|
29
|
-
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t3/unrelated')).toBe(false)
|
|
30
|
-
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t3/recovery/impersonate')).toBe(false)
|
|
31
|
-
expect(isMfaPendingAccessAllowed('POST', '/api/auth/login')).toBe(false)
|
|
32
|
-
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t3/recovery-codes')).toBe(false)
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('is fail-closed on missing or malformed input', () => {
|
|
36
|
-
registerMfaPendingAccessRoutes([{ path: '/api/acme-mfa/t4/complete', methods: ['POST'] }])
|
|
37
|
-
|
|
38
|
-
expect(isMfaPendingAccessAllowed(undefined, '/api/acme-mfa/t4/complete')).toBe(false)
|
|
39
|
-
expect(isMfaPendingAccessAllowed(null, '/api/acme-mfa/t4/complete')).toBe(false)
|
|
40
|
-
expect(isMfaPendingAccessAllowed('', '')).toBe(false)
|
|
41
|
-
expect(isMfaPendingAccessAllowed('POST', undefined)).toBe(false)
|
|
42
|
-
expect(isMfaPendingAccessAllowed('POST', 'not-a-path')).toBe(false)
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('registers additional routes additively and idempotently', () => {
|
|
46
|
-
const routeCountBefore = listMfaPendingAccessRoutes().length
|
|
47
|
-
|
|
48
|
-
registerMfaPendingAccessRoutes([
|
|
49
|
-
{ path: '/api/acme-mfa/t5/complete', methods: ['post', 'POST'] },
|
|
50
|
-
])
|
|
51
|
-
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t5/complete')).toBe(true)
|
|
52
|
-
|
|
53
|
-
registerMfaPendingAccessRoutes([
|
|
54
|
-
{ path: '/api/acme-mfa/t5/complete/', methods: ['POST'] },
|
|
55
|
-
{ path: '/api/acme-mfa/t5/complete', methods: ['GET'] },
|
|
56
|
-
])
|
|
57
|
-
const merged = listMfaPendingAccessRoutes().find((route) => route.path === '/api/acme-mfa/t5/complete')
|
|
58
|
-
expect(merged).toBeDefined()
|
|
59
|
-
expect(merged?.methods).toEqual(['GET', 'POST'])
|
|
60
|
-
expect(listMfaPendingAccessRoutes()).toHaveLength(routeCountBefore + 1)
|
|
61
|
-
|
|
62
|
-
registerMfaPendingAccessRoutes([
|
|
63
|
-
{ path: 'no-leading-slash', methods: [] },
|
|
64
|
-
{ path: '/api/acme-mfa/t5/broken', methods: [] },
|
|
65
|
-
] as never)
|
|
66
|
-
expect(listMfaPendingAccessRoutes()).toHaveLength(routeCountBefore + 1)
|
|
67
|
-
expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t5/broken')).toBe(false)
|
|
68
|
-
})
|
|
69
|
-
})
|