@open-mercato/shared 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6785.1.1dd7cfac55

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 (52) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +18 -3
  3. package/dist/lib/ai/safety-identifier.js +32 -0
  4. package/dist/lib/ai/safety-identifier.js.map +7 -0
  5. package/dist/lib/commands/command-bus.js +5 -1
  6. package/dist/lib/commands/command-bus.js.map +2 -2
  7. package/dist/lib/commands/command-interceptor-runner.js +2 -2
  8. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  9. package/dist/lib/crud/enricher-runner.js +2 -11
  10. package/dist/lib/crud/enricher-runner.js.map +2 -2
  11. package/dist/lib/crud/factory.js +4 -2
  12. package/dist/lib/crud/factory.js.map +2 -2
  13. package/dist/lib/crud/interceptor-runner.js +2 -2
  14. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  15. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  16. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  17. package/dist/lib/crud/types.js +4 -0
  18. package/dist/lib/crud/types.js.map +3 -3
  19. package/dist/lib/data/consistency.js +19 -0
  20. package/dist/lib/data/consistency.js.map +7 -0
  21. package/dist/lib/data/engine.js +37 -11
  22. package/dist/lib/data/engine.js.map +2 -2
  23. package/dist/lib/version.js +1 -1
  24. package/dist/lib/version.js.map +1 -1
  25. package/dist/security/enabledModulesRegistry.js +56 -11
  26. package/dist/security/enabledModulesRegistry.js.map +2 -2
  27. package/dist/security/featurePolicy.js +62 -0
  28. package/dist/security/featurePolicy.js.map +7 -0
  29. package/package.json +6 -2
  30. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
  31. package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
  32. package/src/lib/ai/llm-provider.ts +33 -0
  33. package/src/lib/ai/safety-identifier.ts +73 -0
  34. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
  35. package/src/lib/commands/command-bus.ts +5 -1
  36. package/src/lib/commands/command-interceptor-runner.ts +2 -2
  37. package/src/lib/crud/__tests__/crud-factory.test.ts +18 -0
  38. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
  39. package/src/lib/crud/enricher-runner.ts +2 -11
  40. package/src/lib/crud/factory.ts +2 -0
  41. package/src/lib/crud/interceptor-runner.ts +2 -2
  42. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  43. package/src/lib/crud/types.ts +3 -0
  44. package/src/lib/data/__tests__/consistency.test.ts +38 -0
  45. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
  46. package/src/lib/data/consistency.ts +17 -0
  47. package/src/lib/data/engine.ts +50 -16
  48. package/src/modules/customer-auth.ts +1 -0
  49. package/src/modules/navigation/backendChrome.ts +1 -0
  50. package/src/security/__tests__/featurePolicy.test.ts +166 -0
  51. package/src/security/enabledModulesRegistry.ts +64 -12
  52. package/src/security/featurePolicy.ts +89 -0
@@ -1,5 +1,9 @@
1
1
  import { matchesEntity, runMutationGuards } from '../mutation-guard-registry'
2
2
  import type { MutationGuard, MutationGuardInput } from '../mutation-guard-registry'
3
+ import {
4
+ applyAclFeatureOverrides,
5
+ resetModuleContractOverridesForTests,
6
+ } from '../../../modules/overrides'
3
7
 
4
8
  describe('matchesEntity', () => {
5
9
  it('matches wildcard "*" against any entity', () => {
@@ -24,6 +28,10 @@ describe('matchesEntity', () => {
24
28
  })
25
29
 
26
30
  describe('runMutationGuards', () => {
31
+ afterEach(() => {
32
+ resetModuleContractOverridesForTests()
33
+ })
34
+
27
35
  const baseInput: MutationGuardInput = {
28
36
  tenantId: 'tenant-1',
29
37
  organizationId: 'org-1',
@@ -159,6 +167,24 @@ describe('runMutationGuards', () => {
159
167
  expect(resultWithWildcard.errorBody).toEqual({ error: 'Blocked by wildcard', guardId: 'g1' })
160
168
  })
161
169
 
170
+ it('does not run a guard gated by a nulled ACL feature', async () => {
171
+ applyAclFeatureOverrides({ 'premium.locks': null })
172
+ const guard = makeGuard({
173
+ id: 'g1',
174
+ features: ['premium.locks'],
175
+ validate: jest.fn().mockResolvedValue({ ok: false }),
176
+ })
177
+
178
+ const result = await runMutationGuards(
179
+ [guard],
180
+ baseInput,
181
+ { userFeatures: ['*', 'premium.locks'] },
182
+ )
183
+
184
+ expect(result.ok).toBe(true)
185
+ expect(guard.validate).not.toHaveBeenCalled()
186
+ })
187
+
162
188
  it('uses custom error body when provided', async () => {
163
189
  const guard = makeGuard({
164
190
  id: 'g1',
@@ -15,6 +15,7 @@ import type {
15
15
  import { getEnrichersForEntity } from './enricher-registry'
16
16
  import { logEnricherTiming } from '../umes/enricher-timing'
17
17
  import { createLogger } from '../logger'
18
+ import { authorizeFeatures } from '../../security/featurePolicy'
18
19
 
19
20
  const logger = createLogger('shared').child({ component: 'umes' })
20
21
 
@@ -35,17 +36,7 @@ function hasRequiredFeatures(
35
36
  ): boolean {
36
37
  if (!enricher.features || enricher.features.length === 0) return true
37
38
  if (!userFeatures) return false
38
- const hasFeature = (required: string): boolean => {
39
- for (const granted of userFeatures) {
40
- if (granted === '*' || granted === required) return true
41
- if (granted.endsWith('.*')) {
42
- const prefix = granted.slice(0, -1)
43
- if (required.startsWith(prefix)) return true
44
- }
45
- }
46
- return false
47
- }
48
- return enricher.features.every((feature) => hasFeature(feature))
39
+ return authorizeFeatures(enricher.features, { grantedFeatures: userFeatures })
49
40
  }
50
41
 
51
42
  function filterByACLAndTenant(
@@ -2333,6 +2333,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2333
2333
  organizationId: targetOrgId,
2334
2334
  tenantId: writeTenantId,
2335
2335
  values,
2336
+ notify: false,
2336
2337
  })
2337
2338
  }
2338
2339
  }
@@ -2670,6 +2671,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2670
2671
  organizationId: targetOrgId,
2671
2672
  tenantId: writeTenantId,
2672
2673
  values,
2674
+ notify: false,
2673
2675
  })
2674
2676
  }
2675
2677
  }
@@ -6,7 +6,7 @@ import type {
6
6
  InterceptorBeforeResult,
7
7
  } from './api-interceptor'
8
8
  import { getApiInterceptorsForRoute } from './interceptor-registry'
9
- import { hasAllFeatures } from '../../security/features'
9
+ import { authorizeFeatures } from '../../security/featurePolicy'
10
10
  import { logInterceptorActivity } from '../umes/interceptor-activity'
11
11
 
12
12
  const DEFAULT_TIMEOUT_MS = 5000
@@ -39,7 +39,7 @@ function sanitizeObject(input?: Record<string, unknown>): Record<string, unknown
39
39
  }
40
40
 
41
41
  function hasRequiredFeatures(features: string[] | undefined, userFeatures: string[] | undefined): boolean {
42
- return hasAllFeatures(userFeatures, features)
42
+ return authorizeFeatures(features ?? [], { grantedFeatures: userFeatures ?? [] })
43
43
  }
44
44
 
45
45
  function timeoutPromise(ms: number, interceptorId: string): Promise<never> {
@@ -1,5 +1,5 @@
1
1
  import type { AwilixContainer } from 'awilix'
2
- import { hasAllFeatures } from '../../security/features'
2
+ import { authorizeFeatures } from '../../security/featurePolicy'
3
3
  import { resolveCrudMutationGuardService } from './mutation-guard-service'
4
4
 
5
5
  // ---------------------------------------------------------------------------
@@ -101,7 +101,7 @@ export async function runMutationGuards(
101
101
  const matching = guards
102
102
  .filter((g) => matchesEntity(g.targetEntity, input.resourceKind))
103
103
  .filter((g) => g.operations.includes(input.operation))
104
- .filter((g) => hasAllFeatures(context.userFeatures, g.features))
104
+ .filter((g) => authorizeFeatures(g.features ?? [], { grantedFeatures: context.userFeatures }))
105
105
  .sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50))
106
106
 
107
107
  let payload = input.mutationPayload
@@ -1,5 +1,8 @@
1
1
  export type CrudEventAction = 'created' | 'updated' | 'deleted'
2
2
 
3
+ /** Internal payload marker: the data engine owns this CRUD event's query-index decision. */
4
+ export const CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY = '__omQueryIndexManaged' as const
5
+
3
6
  export type CrudEntityIdentifiers = {
4
7
  id: string
5
8
  organizationId: string | null
@@ -0,0 +1,38 @@
1
+ import {
2
+ __resetAlwaysConsistentCacheForTests,
3
+ isReadProjectionAlwaysConsistent,
4
+ parseAlwaysConsistentEnv,
5
+ } from '../consistency'
6
+
7
+ describe('read projection consistency flag', () => {
8
+ const originalEnv = process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT
9
+
10
+ afterEach(() => {
11
+ if (originalEnv === undefined) {
12
+ delete process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT
13
+ } else {
14
+ process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT = originalEnv
15
+ }
16
+ __resetAlwaysConsistentCacheForTests()
17
+ })
18
+
19
+ it.each([undefined, null, '', ' ', 'off', 'false', '0', 'no', 'disabled', 'none', 'unexpected'])(
20
+ 'parses %p as OFF',
21
+ (raw) => {
22
+ expect(parseAlwaysConsistentEnv(raw)).toBe(false)
23
+ },
24
+ )
25
+
26
+ it.each(['on', 'true', '1', 'yes', 'enabled'])('parses %p as ON', (raw) => {
27
+ expect(parseAlwaysConsistentEnv(raw)).toBe(true)
28
+ })
29
+
30
+ it('memoizes the env value until reset for tests', () => {
31
+ process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT = 'on'
32
+ expect(isReadProjectionAlwaysConsistent()).toBe(true)
33
+ process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT = 'off'
34
+ expect(isReadProjectionAlwaysConsistent()).toBe(true)
35
+ __resetAlwaysConsistentCacheForTests()
36
+ expect(isReadProjectionAlwaysConsistent()).toBe(false)
37
+ })
38
+ })
@@ -1,7 +1,11 @@
1
1
  import type { AwilixContainer } from 'awilix'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import { DefaultDataEngine } from '../engine'
4
- import type { CrudEventsConfig, CrudIndexerConfig } from '../../crud/types'
4
+ import {
5
+ CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,
6
+ type CrudEventsConfig,
7
+ type CrudIndexerConfig,
8
+ } from '../../crud/types'
5
9
 
6
10
  // The bulk-import deferral (`suppress`) must gate the two per-record side effects `emitOrmEntityEvent`
7
11
  // fans out — the `<module>.<entity>.<action>` domain event and the inline `query_index.upsert_one`
@@ -25,6 +29,15 @@ function buildEngine() {
25
29
  return { engine, emitEvent, emittedNames }
26
30
  }
27
31
 
32
+ function expectManagedDomainPayload(emitEvent: jest.Mock, eventName: string): void {
33
+ const call = emitEvent.mock.calls.find(([name]) => name === eventName)
34
+ expect(call).toBeDefined()
35
+ const payload = call?.[1] as Record<string, unknown>
36
+ expect(payload[CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY]).toBe(true)
37
+ expect(Object.keys(payload)).not.toContain(CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY)
38
+ expect(JSON.stringify(payload)).not.toContain(CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY)
39
+ }
40
+
28
41
  describe('DefaultDataEngine bulk-import suppression', () => {
29
42
  // The test events are intentionally not registered in the event registry; silence the
30
43
  // one-time "undeclared event" warning so it doesn't clutter the suite output.
@@ -33,9 +46,10 @@ describe('DefaultDataEngine bulk-import suppression', () => {
33
46
  afterAll(() => { warnSpy.mockRestore() })
34
47
 
35
48
  it('emits both the domain event and the reindex when unsuppressed', async () => {
36
- const { engine, emittedNames } = buildEngine()
49
+ const { engine, emitEvent, emittedNames } = buildEngine()
37
50
  await engine.emitOrmEntityEvent({ action: 'created', entity: {}, events: EVENTS, indexer: INDEXER, identifiers: IDENTIFIERS })
38
51
  expect(emittedNames()).toEqual(expect.arrayContaining(['sales.order.created', 'query_index.upsert_one']))
52
+ expectManagedDomainPayload(emitEvent, 'sales.order.created')
39
53
  })
40
54
 
41
55
  it('skips the domain event but keeps the reindex with skipEvents', async () => {
@@ -47,11 +61,12 @@ describe('DefaultDataEngine bulk-import suppression', () => {
47
61
  })
48
62
 
49
63
  it('skips the reindex but keeps the domain event with skipReindex', async () => {
50
- const { engine, emittedNames } = buildEngine()
64
+ const { engine, emitEvent, emittedNames } = buildEngine()
51
65
  await engine.emitOrmEntityEvent({ action: 'created', entity: {}, events: EVENTS, indexer: INDEXER, identifiers: IDENTIFIERS, suppress: { skipReindex: true } })
52
66
  const names = emittedNames()
53
67
  expect(names).toContain('sales.order.created')
54
68
  expect(names).not.toContain('query_index.upsert_one')
69
+ expectManagedDomainPayload(emitEvent, 'sales.order.created')
55
70
  })
56
71
 
57
72
  it('emits nothing when both are suppressed', async () => {
@@ -0,0 +1,17 @@
1
+ import { parseBooleanWithDefault } from '../boolean'
2
+
3
+ let alwaysConsistentFlag: boolean | null = null
4
+
5
+ export function parseAlwaysConsistentEnv(raw: string | undefined | null): boolean {
6
+ return parseBooleanWithDefault(raw, false)
7
+ }
8
+
9
+ export function isReadProjectionAlwaysConsistent(): boolean {
10
+ if (alwaysConsistentFlag !== null) return alwaysConsistentFlag
11
+ alwaysConsistentFlag = parseAlwaysConsistentEnv(process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT)
12
+ return alwaysConsistentFlag
13
+ }
14
+
15
+ export function __resetAlwaysConsistentCacheForTests(): void {
16
+ alwaysConsistentFlag = null
17
+ }
@@ -6,11 +6,12 @@ import { setRecordCustomFields } from '@open-mercato/core/modules/entities/lib/h
6
6
  import { validateCustomFieldValuesServer } from '@open-mercato/core/modules/entities/lib/validation'
7
7
  import { sanitizeCustomFieldHtmlRichTextValuesServer } from '@open-mercato/core/modules/entities/lib/htmlRichTextSanitizer'
8
8
  import type { EventBus } from '@open-mercato/events/types'
9
- import type {
10
- CrudEventAction,
11
- CrudEventsConfig,
12
- CrudIndexerConfig,
13
- CrudEntityIdentifiers,
9
+ import {
10
+ CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,
11
+ type CrudEventAction,
12
+ type CrudEventsConfig,
13
+ type CrudIndexerConfig,
14
+ type CrudEntityIdentifiers,
14
15
  } from '../crud/types'
15
16
  import type { BulkImportSuppression } from '../commands/types'
16
17
  import { CrudHttpError } from '../crud/errors'
@@ -18,6 +19,7 @@ import { resolveRegisteredEntityTableName } from '../query/engine'
18
19
  import { getEntityIds } from '../encryption/entityIds'
19
20
  import { normalizeCustomFieldValues } from '../custom-fields/normalize'
20
21
  import { parseBooleanToken } from '../boolean'
22
+ import { isReadProjectionAlwaysConsistent } from './consistency'
21
23
  import { isEventDeclared } from '../../modules/events'
22
24
  import { createLogger } from '../logger'
23
25
 
@@ -598,7 +600,7 @@ export class DefaultDataEngine implements DataEngine {
598
600
  if (events && !suppress?.skipEvents) {
599
601
  const eventName = `${events.module}.${events.entity}.${action}`
600
602
  warnIfUndeclaredEvent(eventName, 'emitOrmEntityEvent')
601
- const payload = events.buildPayload
603
+ const builtPayload = events.buildPayload
602
604
  ? events.buildPayload(ctx)
603
605
  : {
604
606
  id: ctx.identifiers.id,
@@ -606,6 +608,20 @@ export class DefaultDataEngine implements DataEngine {
606
608
  tenantId: ctx.identifiers.tenantId,
607
609
  ...(ctx.syncOrigin ? { syncOrigin: ctx.syncOrigin } : {}),
608
610
  }
611
+ // A configured indexer means this data-engine call owns the query-index
612
+ // decision, including an explicit skipReindex suppression. Mark object
613
+ // payloads so the legacy domain-event bridge does not enqueue the same
614
+ // record a second time. Keep the marker non-enumerable so client broadcasts
615
+ // and persisted domain payloads retain their existing public shape.
616
+ // Primitive custom payloads cannot be bridged in any case because they do
617
+ // not expose the record id.
618
+ const payload = indexer && builtPayload && typeof builtPayload === 'object' && !Array.isArray(builtPayload)
619
+ ? Object.defineProperty(
620
+ { ...(builtPayload as Record<string, unknown>) },
621
+ CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,
622
+ { value: true, enumerable: false },
623
+ )
624
+ : builtPayload
609
625
  try {
610
626
  await bus.emitEvent(eventName, payload, {
611
627
  persistent: !!events.persistent,
@@ -618,6 +634,7 @@ export class DefaultDataEngine implements DataEngine {
618
634
  }
619
635
 
620
636
  if (indexer && !suppress?.skipReindex) {
637
+ const alwaysConsistent = isReadProjectionAlwaysConsistent()
621
638
  const resolveCoverageBaseDelta = (): number | undefined => {
622
639
  if (action === 'created') return 1
623
640
  if (action === 'deleted') return -1
@@ -643,9 +660,14 @@ export class DefaultDataEngine implements DataEngine {
643
660
  // returns. The subscriber removes the projection row + tokens synchronously and
644
661
  // defers the coverage recompute + fulltext delete, so this stays bounded.
645
662
  // Errors are logged, not thrown — index drift never fails the originating write.
646
- await bus.emitEvent('query_index.delete_one', enrichedPayload).catch((err: unknown) => {
647
- logger.error('query_index.delete_one emit failed', { err })
648
- })
663
+ // Always-consistent mode rethrows so drift is loud and retryable.
664
+ if (alwaysConsistent) {
665
+ await bus.emitEvent('query_index.delete_one', enrichedPayload, { rethrowHandlerErrors: true })
666
+ } else {
667
+ await bus.emitEvent('query_index.delete_one', enrichedPayload).catch((err: unknown) => {
668
+ logger.error('query_index.delete_one emit failed', { err })
669
+ })
670
+ }
649
671
  } else {
650
672
  const payload = indexer.buildUpsertPayload
651
673
  ? indexer.buildUpsertPayload(ctx)
@@ -663,18 +685,27 @@ export class DefaultDataEngine implements DataEngine {
663
685
  // (see delete_one above). The subscriber updates `entity_indexes` synchronously
664
686
  // and defers the heavy token-reindex pipeline (build doc + encrypt + decrypt +
665
687
  // tokenize + DELETE + chunked INSERT) so write latency stays bounded.
666
- await bus.emitEvent('query_index.upsert_one', enrichedPayload).catch((err: unknown) => {
667
- logger.error('query_index.upsert_one emit failed', { err })
668
- })
688
+ if (alwaysConsistent) {
689
+ await bus.emitEvent('query_index.upsert_one', enrichedPayload, { rethrowHandlerErrors: true })
690
+ } else {
691
+ await bus.emitEvent('query_index.upsert_one', enrichedPayload).catch((err: unknown) => {
692
+ logger.error('query_index.upsert_one emit failed', { err })
693
+ })
694
+ }
669
695
  }
670
696
 
671
- if (shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {
672
- void bus.emitEvent('query_index.coverage.refresh', {
697
+ if (alwaysConsistent || shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {
698
+ const coveragePayload = {
673
699
  entityType: indexer.entityType,
674
700
  tenantId: ctx.identifiers.tenantId ?? null,
675
701
  organizationId: null,
676
702
  delayMs: 0,
677
- }).catch(() => undefined)
703
+ }
704
+ if (alwaysConsistent) {
705
+ await bus.emitEvent('query_index.coverage.refresh', coveragePayload, { rethrowHandlerErrors: true })
706
+ } else {
707
+ void bus.emitEvent('query_index.coverage.refresh', coveragePayload).catch(() => undefined)
708
+ }
678
709
  }
679
710
  }
680
711
  }
@@ -739,7 +770,10 @@ export class DefaultDataEngine implements DataEngine {
739
770
  indexer: entry.indexer as CrudIndexerConfig<unknown>,
740
771
  suppress,
741
772
  })
742
- } catch {
773
+ } catch (error) {
774
+ if (isReadProjectionAlwaysConsistent()) {
775
+ throw error
776
+ }
743
777
  // best-effort; continue with remaining side effects
744
778
  }
745
779
  }
@@ -20,6 +20,7 @@ export interface CustomerAuthContext {
20
20
  customerEntityId?: string | null
21
21
  personEntityId?: string | null
22
22
  resolvedFeatures: string[]
23
+ isPortalAdmin?: boolean
23
24
  }
24
25
 
25
26
  export type CustomerUser = {
@@ -44,6 +44,7 @@ export type BackendChromeBrand = {
44
44
  logo?: {
45
45
  src: string
46
46
  alt?: string
47
+ preserveAspectRatio?: boolean
47
48
  } | null
48
49
  }
49
50
 
@@ -0,0 +1,166 @@
1
+ import type { Module } from '../../modules/registry'
2
+ import { getModules } from '../../lib/modules/registry'
3
+ import {
4
+ applyAclFeatureOverrides,
5
+ resetModuleContractOverridesForTests,
6
+ } from '../../modules/overrides'
7
+ import {
8
+ authorizeFeatures,
9
+ getRemovedAclFeatureIds,
10
+ isAclFeatureRemoved,
11
+ resolveEffectiveFeatures,
12
+ } from '../featurePolicy'
13
+
14
+ jest.mock('../../lib/modules/registry', () => ({
15
+ getModules: jest.fn(),
16
+ }))
17
+
18
+ const mockGetModules = jest.mocked(getModules)
19
+
20
+ const modules: Module[] = [
21
+ {
22
+ id: 'auth',
23
+ features: [
24
+ { id: 'auth.users.view', title: 'View users', module: 'auth' },
25
+ { id: 'auth.users.manage', title: 'Manage users', module: 'auth' },
26
+ ],
27
+ },
28
+ {
29
+ id: 'dashboards',
30
+ features: [
31
+ { id: 'analytics.view', title: 'View analytics', module: 'dashboards' },
32
+ ],
33
+ },
34
+ {
35
+ id: 'customer_accounts',
36
+ setup: {
37
+ defaultCustomerRoleFeatures: {
38
+ portal_admin: ['portal.*'],
39
+ buyer: ['portal.orders.view', 'portal.account.manage'],
40
+ },
41
+ },
42
+ frontendRoutes: [
43
+ {
44
+ Component: () => null,
45
+ requireCustomerFeatures: ['portal.quotes.view'],
46
+ },
47
+ ],
48
+ },
49
+ ]
50
+
51
+ describe('featurePolicy', () => {
52
+ beforeEach(() => {
53
+ mockGetModules.mockReturnValue(modules)
54
+ resetModuleContractOverridesForTests()
55
+ })
56
+
57
+ afterEach(() => {
58
+ resetModuleContractOverridesForTests()
59
+ jest.resetAllMocks()
60
+ })
61
+
62
+ it('reports exact removals and lets a later replacement restore the feature', () => {
63
+ applyAclFeatureOverrides({
64
+ 'auth.users.manage': null,
65
+ 'legacy.feature': null,
66
+ })
67
+
68
+ expect(getRemovedAclFeatureIds()).toEqual(['auth.users.manage', 'legacy.feature'])
69
+ expect(isAclFeatureRemoved('auth.users.manage')).toBe(true)
70
+
71
+ applyAclFeatureOverrides({
72
+ 'auth.users.manage': {
73
+ id: 'auth.users.manage',
74
+ title: 'Manage users',
75
+ module: 'auth',
76
+ },
77
+ })
78
+
79
+ expect(isAclFeatureRemoved('auth.users.manage')).toBe(false)
80
+ expect(getRemovedAclFeatureIds()).toEqual(['legacy.feature'])
81
+ })
82
+
83
+ it.each([
84
+ { grantedFeatures: ['auth.users.manage'], unrestricted: false, siblingAllowed: false },
85
+ { grantedFeatures: ['auth.*'], unrestricted: false, siblingAllowed: true },
86
+ { grantedFeatures: ['*'], unrestricted: false, siblingAllowed: true },
87
+ { grantedFeatures: [], unrestricted: true, siblingAllowed: true },
88
+ ])('denies a removed requirement before grants or unrestricted access', ({
89
+ siblingAllowed,
90
+ ...subject
91
+ }) => {
92
+ applyAclFeatureOverrides({ 'auth.users.manage': null })
93
+
94
+ expect(authorizeFeatures(['auth.users.manage'], subject)).toBe(false)
95
+ expect(authorizeFeatures(['auth.users.view'], subject)).toBe(siblingAllowed)
96
+ })
97
+
98
+ it('enforces invalid scope before unrestricted access', () => {
99
+ expect(authorizeFeatures(['auth.users.view'], {
100
+ grantedFeatures: ['*'],
101
+ unrestricted: true,
102
+ scopeAllowed: false,
103
+ })).toBe(false)
104
+ })
105
+
106
+ it('denies requirements owned by disabled modules', () => {
107
+ expect(authorizeFeatures(['search.global'], {
108
+ grantedFeatures: ['*'],
109
+ unrestricted: true,
110
+ })).toBe(false)
111
+ })
112
+
113
+ it('expands wildcards into a deterministic concrete set including portal sources', () => {
114
+ expect(resolveEffectiveFeatures(['*'])).toEqual([
115
+ 'auth.users.view',
116
+ 'auth.users.manage',
117
+ 'analytics.view',
118
+ 'portal.orders.view',
119
+ 'portal.account.manage',
120
+ 'portal.quotes.view',
121
+ ])
122
+ expect(resolveEffectiveFeatures(['portal.*'])).toEqual([
123
+ 'portal.orders.view',
124
+ 'portal.account.manage',
125
+ 'portal.quotes.view',
126
+ ])
127
+ })
128
+
129
+ it('removes nulled features while preserving siblings and explicit custom grants', () => {
130
+ applyAclFeatureOverrides({
131
+ 'auth.users.manage': null,
132
+ 'auth.custom.audit': null,
133
+ })
134
+
135
+ expect(resolveEffectiveFeatures([
136
+ 'auth.*',
137
+ 'auth.custom.export',
138
+ 'auth.custom.audit',
139
+ ])).toEqual([
140
+ 'auth.users.view',
141
+ 'auth.custom.export',
142
+ ])
143
+ })
144
+
145
+ it('uses declared ownership for off-convention features', () => {
146
+ expect(resolveEffectiveFeatures(['analytics.*'])).toEqual(['analytics.view'])
147
+ expect(authorizeFeatures(['analytics.view'], {
148
+ grantedFeatures: ['analytics.*'],
149
+ })).toBe(true)
150
+ })
151
+
152
+ it('fails closed for wildcards when the module registry is unavailable', () => {
153
+ mockGetModules.mockImplementation(() => {
154
+ throw new Error('registry unavailable')
155
+ })
156
+ applyAclFeatureOverrides({ 'legacy.removed': null })
157
+
158
+ expect(resolveEffectiveFeatures([
159
+ '*',
160
+ 'legacy.*',
161
+ 'legacy.explicit',
162
+ 'legacy.removed',
163
+ 'legacy.explicit',
164
+ ])).toEqual(['legacy.explicit'])
165
+ })
166
+ })
@@ -31,6 +31,7 @@ type FeatureRegistry = {
31
31
  enabledModuleSet: Set<string>
32
32
  featureToModule: Map<string, string>
33
33
  prefixToModule: Map<string, string>
34
+ concreteFeatureIds: string[]
34
35
  }
35
36
 
36
37
  let cachedRegistry: FeatureRegistry | null = null
@@ -41,23 +42,63 @@ function buildRegistry(modules: readonly Module[]): FeatureRegistry {
41
42
  const enabledModuleSet = new Set(enabledModuleIds)
42
43
  const featureToModule = new Map<string, string>()
43
44
  const prefixToModule = new Map<string, string>()
45
+ const concreteFeatureIds: string[] = []
46
+ const concreteFeatureSet = new Set<string>()
47
+
48
+ const addConcreteFeature = (featureId: string, owningModule: string, authoritative: boolean) => {
49
+ if (!featureId || featureId === '*' || featureId.endsWith('.*')) return
50
+ if (!concreteFeatureSet.has(featureId)) {
51
+ concreteFeatureSet.add(featureId)
52
+ concreteFeatureIds.push(featureId)
53
+ }
54
+ if (authoritative || !featureToModule.has(featureId)) {
55
+ featureToModule.set(featureId, owningModule)
56
+ }
57
+ const dot = featureId.indexOf('.')
58
+ if (dot > 0) {
59
+ const prefix = featureId.slice(0, dot)
60
+ if (!prefixToModule.has(prefix)) prefixToModule.set(prefix, owningModule)
61
+ }
62
+ }
63
+
44
64
  for (const mod of modules) {
45
65
  const features = mod.features
46
- if (!Array.isArray(features)) continue
47
- for (const feature of features) {
48
- if (!feature || typeof feature.id !== 'string' || !feature.id) continue
49
- const declared = typeof feature.module === 'string' && feature.module.length > 0
50
- ? feature.module
51
- : mod.id
52
- featureToModule.set(feature.id, declared)
53
- const dot = feature.id.indexOf('.')
54
- if (dot > 0) {
55
- const prefix = feature.id.slice(0, dot)
56
- if (!prefixToModule.has(prefix)) prefixToModule.set(prefix, declared)
66
+ if (Array.isArray(features)) {
67
+ for (const feature of features) {
68
+ if (!feature || typeof feature.id !== 'string' || !feature.id) continue
69
+ const declared = typeof feature.module === 'string' && feature.module.length > 0
70
+ ? feature.module
71
+ : mod.id
72
+ addConcreteFeature(feature.id, declared, true)
73
+ }
74
+ }
75
+
76
+ const customerDefaults = mod.setup?.defaultCustomerRoleFeatures
77
+ if (customerDefaults) {
78
+ for (const roleFeatures of Object.values(customerDefaults)) {
79
+ if (!Array.isArray(roleFeatures)) continue
80
+ for (const featureId of roleFeatures) {
81
+ if (typeof featureId === 'string') addConcreteFeature(featureId, mod.id, false)
82
+ }
57
83
  }
58
84
  }
85
+
86
+ if (Array.isArray(mod.frontendRoutes)) {
87
+ for (const route of mod.frontendRoutes) {
88
+ if (!Array.isArray(route.requireCustomerFeatures)) continue
89
+ for (const featureId of route.requireCustomerFeatures) {
90
+ if (typeof featureId === 'string') addConcreteFeature(featureId, mod.id, false)
91
+ }
92
+ }
93
+ }
94
+ }
95
+ return {
96
+ enabledModuleIds,
97
+ enabledModuleSet,
98
+ featureToModule,
99
+ prefixToModule,
100
+ concreteFeatureIds,
59
101
  }
60
- return { enabledModuleIds, enabledModuleSet, featureToModule, prefixToModule }
61
102
  }
62
103
 
63
104
  function getRegistry(): FeatureRegistry | null {
@@ -93,6 +134,17 @@ export function getEnabledModuleIds(): string[] {
93
134
  return registry ? [...registry.enabledModuleIds] : []
94
135
  }
95
136
 
137
+ /** @internal Infrastructure input for concrete feature-policy projection. */
138
+ export function getConcreteFeatureIds(): string[] {
139
+ const registry = getRegistry()
140
+ return registry ? [...registry.concreteFeatureIds] : []
141
+ }
142
+
143
+ /** @internal Distinguishes an empty registry from an unavailable bootstrap registry. */
144
+ export function hasEnabledModulesRegistry(): boolean {
145
+ return getRegistry() !== null
146
+ }
147
+
96
148
  /**
97
149
  * Filters a raw granted-features list down to the grants whose owning
98
150
  * module is currently enabled. Expands `*` (superadmin) into one wildcard