@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.
Files changed (81) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -4
  3. package/dist/lib/auth/jwt.js +0 -6
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/organizationAccess.js +4 -7
  6. package/dist/lib/auth/organizationAccess.js.map +2 -2
  7. package/dist/lib/auth/server.js +7 -38
  8. package/dist/lib/auth/server.js.map +2 -2
  9. package/dist/lib/commands/command-bus.js +1 -6
  10. package/dist/lib/commands/command-bus.js.map +2 -2
  11. package/dist/lib/crud/factory.js +8 -24
  12. package/dist/lib/crud/factory.js.map +2 -2
  13. package/dist/lib/data/engine.js +2 -8
  14. package/dist/lib/data/engine.js.map +2 -2
  15. package/dist/lib/location/countries.js +0 -12
  16. package/dist/lib/location/countries.js.map +2 -2
  17. package/dist/lib/openapi/crud.js +1 -4
  18. package/dist/lib/openapi/crud.js.map +2 -2
  19. package/dist/lib/query/engine.js +34 -270
  20. package/dist/lib/query/engine.js.map +3 -3
  21. package/dist/lib/query/types.js.map +1 -1
  22. package/dist/lib/search/config.js +0 -1
  23. package/dist/lib/search/config.js.map +2 -2
  24. package/dist/lib/version.js +1 -1
  25. package/dist/lib/version.js.map +1 -1
  26. package/dist/modules/events/factory.js +15 -69
  27. package/dist/modules/events/factory.js.map +2 -2
  28. package/dist/modules/registry.js +0 -15
  29. package/dist/modules/registry.js.map +2 -2
  30. package/dist/modules/widgets/component-registry.js.map +2 -2
  31. package/package.json +3 -10
  32. package/src/lib/auth/__tests__/jwt.test.ts +0 -13
  33. package/src/lib/auth/__tests__/organizationAccess.test.ts +1 -36
  34. package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +0 -324
  35. package/src/lib/auth/__tests__/server.test.ts +0 -104
  36. package/src/lib/auth/jwt.ts +0 -17
  37. package/src/lib/auth/organizationAccess.ts +3 -11
  38. package/src/lib/auth/server.ts +8 -78
  39. package/src/lib/commands/__tests__/command-bus.test.ts +0 -31
  40. package/src/lib/commands/command-bus.ts +1 -8
  41. package/src/lib/crud/__tests__/crud-factory.test.ts +0 -165
  42. package/src/lib/crud/factory.ts +7 -33
  43. package/src/lib/data/__tests__/engine.event-validation.test.ts +1 -9
  44. package/src/lib/data/engine.ts +1 -7
  45. package/src/lib/location/__tests__/countries.test.ts +0 -15
  46. package/src/lib/location/countries.ts +0 -17
  47. package/src/lib/openapi/crud.ts +0 -3
  48. package/src/lib/query/__tests__/engine.count-distinct.test.ts +15 -162
  49. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +1 -11
  50. package/src/lib/query/__tests__/engine.test.ts +7 -445
  51. package/src/lib/query/engine.ts +54 -434
  52. package/src/lib/query/types.ts +0 -15
  53. package/src/lib/search/config.ts +0 -10
  54. package/src/modules/events/factory.ts +19 -111
  55. package/src/modules/events/types.ts +0 -17
  56. package/src/modules/registry.ts +0 -40
  57. package/src/modules/widgets/component-registry.ts +0 -14
  58. package/dist/lib/auth/mfaPendingAccess.js +0 -42
  59. package/dist/lib/auth/mfaPendingAccess.js.map +0 -7
  60. package/dist/lib/auth/principal-service.js +0 -1
  61. package/dist/lib/auth/principal-service.js.map +0 -7
  62. package/dist/lib/html/htmlToPlainText.js +0 -16
  63. package/dist/lib/html/htmlToPlainText.js.map +0 -7
  64. package/dist/lib/query/count-cap.js +0 -11
  65. package/dist/lib/query/count-cap.js.map +0 -7
  66. package/dist/lib/queue/dispatchOrigin.js +0 -20
  67. package/dist/lib/queue/dispatchOrigin.js.map +0 -7
  68. package/dist/lib/search/entityAccess.js +0 -44
  69. package/dist/lib/search/entityAccess.js.map +0 -7
  70. package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +0 -69
  71. package/src/lib/auth/__tests__/principalServiceExport.test.ts +0 -67
  72. package/src/lib/auth/mfaPendingAccess.ts +0 -70
  73. package/src/lib/auth/principal-service.ts +0 -110
  74. package/src/lib/html/__tests__/htmlToPlainText.test.ts +0 -59
  75. package/src/lib/html/htmlToPlainText.ts +0 -17
  76. package/src/lib/query/__tests__/count-cap-plan.test.ts +0 -240
  77. package/src/lib/query/__tests__/count-cap.test.ts +0 -41
  78. package/src/lib/query/count-cap.ts +0 -19
  79. package/src/lib/queue/dispatchOrigin.ts +0 -35
  80. package/src/lib/search/entityAccess.ts +0 -132
  81. package/src/modules/events/__tests__/factory.test.ts +0 -88
@@ -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> = {
@@ -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
- 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
- }
68
+ // Global set of all declared event IDs for runtime validation
69
+ const allDeclaredEventIds = new Set<string>()
89
70
 
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
- }
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
- 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
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 getEventRegistryState().declaredEventIds.has(eventId)
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(getEventRegistryState().declaredEventIds)
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 [...getEventRegistryState().declaredEvents]
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 = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
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 = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
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
- const state = getEventRegistryState()
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
- state.registeredEventConfigs = configs
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 getEventRegistryState().registeredEventConfigs ?? []
151
+ return _registeredEventConfigs ?? []
224
152
  }
225
153
 
226
154
  // =============================================================================
@@ -311,27 +239,7 @@ export function createModuleEvents<
311
239
  return
312
240
  }
313
241
 
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)
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
  // =============================================================================
@@ -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,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": [],
4
- "sourcesContent": [],
5
- "mappings": "",
6
- "names": []
7
- }
@@ -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
- })