@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +18 -3
- package/dist/lib/ai/safety-identifier.js +32 -0
- package/dist/lib/ai/safety-identifier.js.map +7 -0
- package/dist/lib/commands/command-bus.js +5 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +2 -2
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +2 -11
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +4 -2
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/interceptor-runner.js +2 -2
- package/dist/lib/crud/interceptor-runner.js.map +2 -2
- package/dist/lib/crud/mutation-guard-registry.js +2 -2
- package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
- package/dist/lib/crud/types.js +4 -0
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/consistency.js +19 -0
- package/dist/lib/data/consistency.js.map +7 -0
- package/dist/lib/data/engine.js +37 -11
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/security/enabledModulesRegistry.js +56 -11
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/dist/security/featurePolicy.js +62 -0
- package/dist/security/featurePolicy.js.map +7 -0
- package/package.json +6 -2
- package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
- package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
- package/src/lib/ai/llm-provider.ts +33 -0
- package/src/lib/ai/safety-identifier.ts +73 -0
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
- package/src/lib/commands/command-bus.ts +5 -1
- package/src/lib/commands/command-interceptor-runner.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +18 -0
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
- package/src/lib/crud/enricher-runner.ts +2 -11
- package/src/lib/crud/factory.ts +2 -0
- package/src/lib/crud/interceptor-runner.ts +2 -2
- package/src/lib/crud/mutation-guard-registry.ts +2 -2
- package/src/lib/crud/types.ts +3 -0
- package/src/lib/data/__tests__/consistency.test.ts +38 -0
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
- package/src/lib/data/consistency.ts +17 -0
- package/src/lib/data/engine.ts +50 -16
- package/src/modules/customer-auth.ts +1 -0
- package/src/modules/navigation/backendChrome.ts +1 -0
- package/src/security/__tests__/featurePolicy.test.ts +166 -0
- package/src/security/enabledModulesRegistry.ts +64 -12
- 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
|
-
|
|
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(
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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 {
|
|
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) =>
|
|
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
|
package/src/lib/crud/types.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/lib/data/engine.ts
CHANGED
|
@@ -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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
647
|
-
|
|
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
|
-
|
|
667
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
}
|
|
@@ -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 (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|