@open-mercato/shared 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6784.1.f80b9afce5

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 (46) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +18 -3
  3. package/dist/lib/commands/command-bus.js +5 -1
  4. package/dist/lib/commands/command-bus.js.map +2 -2
  5. package/dist/lib/commands/command-interceptor-runner.js +2 -2
  6. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  7. package/dist/lib/crud/enricher-runner.js +2 -11
  8. package/dist/lib/crud/enricher-runner.js.map +2 -2
  9. package/dist/lib/crud/factory.js +4 -2
  10. package/dist/lib/crud/factory.js.map +2 -2
  11. package/dist/lib/crud/interceptor-runner.js +2 -2
  12. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  13. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  14. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  15. package/dist/lib/crud/types.js +4 -0
  16. package/dist/lib/crud/types.js.map +3 -3
  17. package/dist/lib/data/consistency.js +19 -0
  18. package/dist/lib/data/consistency.js.map +7 -0
  19. package/dist/lib/data/engine.js +37 -11
  20. package/dist/lib/data/engine.js.map +2 -2
  21. package/dist/lib/version.js +1 -1
  22. package/dist/lib/version.js.map +1 -1
  23. package/dist/security/enabledModulesRegistry.js +56 -11
  24. package/dist/security/enabledModulesRegistry.js.map +2 -2
  25. package/dist/security/featurePolicy.js +62 -0
  26. package/dist/security/featurePolicy.js.map +7 -0
  27. package/package.json +6 -2
  28. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
  29. package/src/lib/commands/command-bus.ts +5 -1
  30. package/src/lib/commands/command-interceptor-runner.ts +2 -2
  31. package/src/lib/crud/__tests__/crud-factory.test.ts +18 -0
  32. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
  33. package/src/lib/crud/enricher-runner.ts +2 -11
  34. package/src/lib/crud/factory.ts +2 -0
  35. package/src/lib/crud/interceptor-runner.ts +2 -2
  36. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  37. package/src/lib/crud/types.ts +3 -0
  38. package/src/lib/data/__tests__/consistency.test.ts +38 -0
  39. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
  40. package/src/lib/data/consistency.ts +17 -0
  41. package/src/lib/data/engine.ts +50 -16
  42. package/src/modules/customer-auth.ts +1 -0
  43. package/src/modules/navigation/backendChrome.ts +1 -0
  44. package/src/security/__tests__/featurePolicy.test.ts +166 -0
  45. package/src/security/enabledModulesRegistry.ts +64 -12
  46. package/src/security/featurePolicy.ts +89 -0
@@ -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
@@ -0,0 +1,89 @@
1
+ import { hasAllFeatures as matchesAllFeatures } from './features'
2
+ import {
3
+ filterGrantsByEnabledModules,
4
+ getConcreteFeatureIds,
5
+ getEnabledModuleIds,
6
+ getOwningModuleId,
7
+ hasEnabledModulesRegistry,
8
+ } from './enabledModulesRegistry'
9
+ import { composeAclFeatureOverrides } from '../modules/overrides'
10
+
11
+ export type FeaturePolicySubject = {
12
+ grantedFeatures: readonly string[]
13
+ unrestricted?: boolean
14
+ scopeAllowed?: boolean
15
+ }
16
+
17
+ export function getRemovedAclFeatureIds(): string[] {
18
+ return Object.entries(composeAclFeatureOverrides())
19
+ .filter(([, override]) => override === null)
20
+ .map(([featureId]) => featureId)
21
+ }
22
+
23
+ export function isAclFeatureRemoved(featureId: string): boolean {
24
+ return composeAclFeatureOverrides()[featureId] === null
25
+ }
26
+
27
+ function isFeatureEnabled(featureId: string): boolean {
28
+ if (!hasEnabledModulesRegistry()) return true
29
+ const enabledModuleIds = getEnabledModuleIds()
30
+ return enabledModuleIds.includes(getOwningModuleId(featureId))
31
+ }
32
+
33
+ export function authorizeFeatures(
34
+ required: readonly string[],
35
+ subject: FeaturePolicySubject,
36
+ ): boolean {
37
+ if (required.length === 0) return true
38
+ if (subject.scopeAllowed === false) return false
39
+ if (required.some((featureId) => (
40
+ isAclFeatureRemoved(featureId) || !isFeatureEnabled(featureId)
41
+ ))) {
42
+ return false
43
+ }
44
+ if (subject.unrestricted === true) return true
45
+ return matchesAllFeatures(
46
+ filterGrantsByEnabledModules(subject.grantedFeatures),
47
+ required,
48
+ )
49
+ }
50
+
51
+ export function resolveEffectiveFeatures(
52
+ grantedFeatures: readonly string[],
53
+ ): string[] {
54
+ const filteredGrants = filterGrantsByEnabledModules(grantedFeatures)
55
+ .filter((featureId) => !isAclFeatureRemoved(featureId))
56
+
57
+ if (!hasEnabledModulesRegistry()) {
58
+ return filteredGrants.filter((featureId, index, features) => (
59
+ featureId !== '*'
60
+ && !featureId.endsWith('.*')
61
+ && features.indexOf(featureId) === index
62
+ ))
63
+ }
64
+
65
+ const result: string[] = []
66
+ const seen = new Set<string>()
67
+ const addFeature = (featureId: string) => {
68
+ if (
69
+ seen.has(featureId)
70
+ || isAclFeatureRemoved(featureId)
71
+ || !isFeatureEnabled(featureId)
72
+ ) {
73
+ return
74
+ }
75
+ seen.add(featureId)
76
+ result.push(featureId)
77
+ }
78
+
79
+ for (const featureId of getConcreteFeatureIds()) {
80
+ if (matchesAllFeatures(filteredGrants, [featureId])) addFeature(featureId)
81
+ }
82
+
83
+ for (const featureId of filteredGrants) {
84
+ if (featureId === '*' || featureId.endsWith('.*')) continue
85
+ addFeature(featureId)
86
+ }
87
+
88
+ return result
89
+ }