@open-mercato/shared 0.6.7 → 0.6.8-develop.6874.1.982d6097d8
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 +57 -6
- package/build.mjs +2 -4
- package/dist/lib/ai/safety-identifier.js +32 -0
- package/dist/lib/ai/safety-identifier.js.map +7 -0
- package/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/auth/server.js +15 -2
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
- package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +117 -43
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- 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 +72 -9
- 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/db/entityMetadata.js +26 -0
- package/dist/lib/db/entityMetadata.js.map +7 -0
- package/dist/lib/db/pg-errors.js +42 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/di/container.js +48 -6
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +56 -0
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +2 -10
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/config.js +1 -1
- package/dist/lib/i18n/config.js.map +2 -2
- package/dist/lib/i18n/server.js +4 -4
- package/dist/lib/i18n/server.js.map +3 -3
- package/dist/lib/logger/extension.js +29 -0
- package/dist/lib/logger/extension.js.map +7 -0
- package/dist/lib/logger/index.js +47 -3
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +5 -1
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/modules/surfaceFingerprint.js +47 -0
- package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
- package/dist/lib/query/ciphertext-search-warning.js +45 -0
- package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
- package/dist/lib/query/engine.js +54 -40
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/redis/connection.js +7 -2
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/search/auto-indexing.js +14 -0
- package/dist/lib/search/auto-indexing.js.map +7 -0
- package/dist/lib/search/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/search/config.js +61 -2
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenLookup.js +46 -0
- package/dist/lib/search/tokenLookup.js.map +7 -0
- package/dist/lib/search/tokenize.js +23 -11
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/telemetry/runtime.js +35 -0
- package/dist/lib/telemetry/runtime.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +57 -0
- package/dist/lib/webhooks/body.js.map +7 -0
- package/dist/lib/webhooks/inbound-types.js +1 -0
- package/dist/lib/webhooks/inbound-types.js.map +7 -0
- package/dist/lib/webhooks/index.js +13 -1
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +12 -7
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/analytics.js.map +2 -2
- package/dist/modules/overrides.js +50 -1
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +8 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js +1 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/extension-points.js +96 -0
- package/dist/modules/widgets/extension-points.js.map +7 -0
- 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/jest.config.cjs +2 -1
- package/package.json +19 -9
- package/scripts/versionSource.cjs +65 -0
- package/src/lib/__tests__/versionSource.test.ts +46 -0
- 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/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/__tests__/server.test.ts +23 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/auth/server.ts +23 -2
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
- package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +213 -65
- 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.cache-user-scope.test.ts +171 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +351 -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 +82 -6
- 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/lib/db/__tests__/entityMetadata.test.ts +38 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
- package/src/lib/db/entityMetadata.ts +43 -0
- package/src/lib/db/pg-errors.ts +58 -0
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
- package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
- package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
- package/src/lib/di/container.ts +72 -8
- package/src/lib/email/__tests__/send.test.ts +37 -1
- package/src/lib/email/send.ts +79 -0
- package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
- package/src/lib/encryption/subscriber.ts +2 -14
- package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
- package/src/lib/i18n/__tests__/config.test.ts +8 -0
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/i18n/server.ts +6 -2
- package/src/lib/logger/__tests__/logger.test.ts +77 -0
- package/src/lib/logger/extension.ts +59 -0
- package/src/lib/logger/index.ts +59 -1
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
- package/src/lib/modules/registry.ts +10 -0
- package/src/lib/modules/surfaceFingerprint.ts +87 -0
- package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
- package/src/lib/query/__tests__/engine.test.ts +65 -13
- package/src/lib/query/ciphertext-search-warning.ts +95 -0
- package/src/lib/query/engine.ts +75 -58
- package/src/lib/query/types.ts +4 -5
- package/src/lib/redis/__tests__/connection.test.ts +16 -0
- package/src/lib/redis/connection.ts +10 -7
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/__tests__/config.test.ts +177 -0
- package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
- package/src/lib/search/__tests__/tokenize.test.ts +49 -0
- package/src/lib/search/auto-indexing.ts +22 -0
- package/src/lib/search/availability.ts +232 -0
- package/src/lib/search/config.ts +106 -8
- package/src/lib/search/tokenLookup.ts +133 -0
- package/src/lib/search/tokenize.ts +33 -11
- package/src/lib/telemetry/runtime.ts +72 -0
- package/src/lib/webhooks/__tests__/body.test.ts +95 -0
- package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
- package/src/lib/webhooks/body.ts +70 -0
- package/src/lib/webhooks/inbound-types.ts +125 -0
- package/src/lib/webhooks/index.ts +19 -1
- package/src/lib/webhooks/verify.ts +17 -9
- package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
- package/src/modules/__tests__/registry.test.ts +10 -2
- package/src/modules/analytics.ts +7 -0
- package/src/modules/customer-auth.ts +1 -0
- package/src/modules/encryption.ts +3 -0
- package/src/modules/navigation/backendChrome.ts +15 -0
- package/src/modules/overrides.ts +103 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
- package/src/modules/payment_gateways/types.ts +41 -0
- package/src/modules/registry.ts +3 -0
- package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
- package/src/modules/widgets/extension-points.ts +524 -0
- package/src/security/__tests__/featurePolicy.test.ts +166 -0
- package/src/security/enabledModulesRegistry.ts +64 -12
- package/src/security/featurePolicy.ts +89 -0
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
registerOptimisticLockReaders,
|
|
11
11
|
} from '@open-mercato/shared/lib/crud/optimistic-lock-store'
|
|
12
12
|
import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
13
|
+
import { registerMutationGuards } from '@open-mercato/shared/lib/crud/mutation-guard-store'
|
|
13
14
|
import { z } from 'zod'
|
|
14
15
|
|
|
15
16
|
// Keep the real custom-field helpers but spy on the definition loader so we can
|
|
@@ -201,6 +202,7 @@ describe('CRUD Factory', () => {
|
|
|
201
202
|
}
|
|
202
203
|
crudMutationGuardService = null
|
|
203
204
|
registerApiInterceptors([])
|
|
205
|
+
registerMutationGuards([])
|
|
204
206
|
})
|
|
205
207
|
|
|
206
208
|
const querySchema = z.object({
|
|
@@ -537,12 +539,66 @@ describe('CRUD Factory', () => {
|
|
|
537
539
|
})
|
|
538
540
|
})
|
|
539
541
|
|
|
542
|
+
describe('export loop termination', () => {
|
|
543
|
+
const EXPORT_PAGE_SIZE = 1000
|
|
544
|
+
|
|
545
|
+
afterEach(() => {
|
|
546
|
+
queryEngine.query.mockImplementation(async (_entityId: any, _q: any) => ({ items: [{ id: 'id-1', title: 'A', is_done: false, organization_id: defaultOrganizationId, tenant_id: defaultTenantId }], total: 1 }))
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
const makeItems = (count: number, offset = 0) =>
|
|
550
|
+
Array.from({ length: count }, (_, index) => ({
|
|
551
|
+
id: `id-${offset + index}`,
|
|
552
|
+
title: `Todo ${offset + index}`,
|
|
553
|
+
is_done: false,
|
|
554
|
+
organization_id: defaultOrganizationId,
|
|
555
|
+
tenant_id: defaultTenantId,
|
|
556
|
+
}))
|
|
557
|
+
|
|
558
|
+
const queuePages = (pages: Array<Array<Record<string, unknown>>>, total: number) => {
|
|
559
|
+
queryEngine.query.mockImplementation(async (_entityId: any, q: any) => {
|
|
560
|
+
const page = q?.page?.page ?? 1
|
|
561
|
+
return { items: pages[page - 1] ?? [], total }
|
|
562
|
+
})
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
it('GET export enumerates every page even when total under-reports the result set', async () => {
|
|
566
|
+
queuePages(
|
|
567
|
+
[makeItems(EXPORT_PAGE_SIZE), makeItems(EXPORT_PAGE_SIZE, EXPORT_PAGE_SIZE), makeItems(5, EXPORT_PAGE_SIZE * 2)],
|
|
568
|
+
3,
|
|
569
|
+
)
|
|
570
|
+
const res = await route.GET(new Request('http://x/api/example/todos?format=json'))
|
|
571
|
+
expect(res.status).toBe(200)
|
|
572
|
+
const parsed = JSON.parse(await res.text())
|
|
573
|
+
expect(parsed).toHaveLength(EXPORT_PAGE_SIZE * 2 + 5)
|
|
574
|
+
expect(queryEngine.query).toHaveBeenCalledTimes(3)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('GET export terminates on a short final page instead of trusting an inflated total', async () => {
|
|
578
|
+
queuePages([makeItems(4)], 10_000)
|
|
579
|
+
const res = await route.GET(new Request('http://x/api/example/todos?format=json'))
|
|
580
|
+
expect(res.status).toBe(200)
|
|
581
|
+
const parsed = JSON.parse(await res.text())
|
|
582
|
+
expect(parsed).toHaveLength(4)
|
|
583
|
+
expect(queryEngine.query).toHaveBeenCalledTimes(1)
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
it('GET export fails closed at the page ceiling rather than serializing a partial export', async () => {
|
|
587
|
+
const fullPage = makeItems(EXPORT_PAGE_SIZE)
|
|
588
|
+
queryEngine.query.mockImplementation(async () => ({ items: fullPage, total: EXPORT_PAGE_SIZE }))
|
|
589
|
+
const res = await route.GET(new Request('http://x/api/example/todos?format=json'))
|
|
590
|
+
expect(res.status).toBe(500)
|
|
591
|
+
expect(queryEngine.query).toHaveBeenCalledTimes(1000)
|
|
592
|
+
})
|
|
593
|
+
})
|
|
594
|
+
|
|
540
595
|
it('POST creates entity, saves custom fields, emits created event', async () => {
|
|
541
596
|
const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'B', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
|
|
542
597
|
expect(res.status).toBe(201)
|
|
543
598
|
const data = await res.json()
|
|
544
599
|
expect(data.id).toBeDefined()
|
|
545
600
|
// CF saved
|
|
601
|
+
expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
|
|
546
602
|
expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ entityId: 'example.todo', values: { priority: 3 } }))
|
|
547
603
|
// Event + indexer delegated to data engine
|
|
548
604
|
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
@@ -569,6 +625,7 @@ describe('CRUD Factory', () => {
|
|
|
569
625
|
await em.persist(created).flush()
|
|
570
626
|
const res = await route.PUT(new Request('http://x/api/example/todos', { method: 'PUT', body: JSON.stringify({ id: created.id, title: 'X2', cf_priority: 5 }), headers: { 'content-type': 'application/json' } }))
|
|
571
627
|
expect(res.status).toBe(200)
|
|
628
|
+
expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
|
|
572
629
|
expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ values: { priority: 5 } }))
|
|
573
630
|
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
574
631
|
const updatedCall = mockDataEngine.emitOrmEntityEvent.mock.calls.at(0)
|
|
@@ -590,6 +647,34 @@ describe('CRUD Factory', () => {
|
|
|
590
647
|
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
591
648
|
})
|
|
592
649
|
|
|
650
|
+
it('returns a retryable 503 when a handler hits a transient DB connection failure', async () => {
|
|
651
|
+
setRecordCustomFields.mockImplementationOnce(async () => {
|
|
652
|
+
throw Object.assign(new Error('sorry, too many clients already'), { code: '53300' })
|
|
653
|
+
})
|
|
654
|
+
const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'Exhausted', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
|
|
655
|
+
expect(res.status).toBe(503)
|
|
656
|
+
expect(res.headers.get('Retry-After')).toBe('2')
|
|
657
|
+
// The failed write is still rolled back — no created event/index leaks out.
|
|
658
|
+
expect(Object.values(db)).toHaveLength(0)
|
|
659
|
+
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('POST surfaces CRUD side-effect failures after custom field writes', async () => {
|
|
663
|
+
mockDataEngine.emitOrmEntityEvent.mockImplementationOnce(async () => {
|
|
664
|
+
throw new Error('index write failed')
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
const res = await route.POST(new Request('http://x/api/example/todos', {
|
|
668
|
+
method: 'POST',
|
|
669
|
+
body: JSON.stringify({ title: 'Indexed', is_done: true, cf_priority: 3 }),
|
|
670
|
+
headers: { 'content-type': 'application/json' },
|
|
671
|
+
}))
|
|
672
|
+
|
|
673
|
+
expect(res.status).toBe(500)
|
|
674
|
+
expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
|
|
675
|
+
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
676
|
+
})
|
|
677
|
+
|
|
593
678
|
it('PUT rolls back the entity update when the custom field write fails', async () => {
|
|
594
679
|
const created = em.create(Todo, { title: 'Before', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
|
|
595
680
|
created.id = '123e4567-e89b-12d3-a456-426614174003'
|
|
@@ -721,6 +806,193 @@ describe('CRUD Factory', () => {
|
|
|
721
806
|
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
722
807
|
})
|
|
723
808
|
|
|
809
|
+
it('POST command route runs mutation guards before executing the command', async () => {
|
|
810
|
+
const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 403, message: 'Blocked by test guard' }))
|
|
811
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
812
|
+
id: 'example.block-command-create',
|
|
813
|
+
targetEntity: 'example.todo',
|
|
814
|
+
operations: ['create'],
|
|
815
|
+
validate: guardValidate,
|
|
816
|
+
}] }])
|
|
817
|
+
const commandRoute = makeCrudRoute({
|
|
818
|
+
metadata: { POST: { requireAuth: true } },
|
|
819
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
820
|
+
indexer: { entityType: 'example.todo' },
|
|
821
|
+
actions: {
|
|
822
|
+
create: {
|
|
823
|
+
commandId: 'example.todo.create',
|
|
824
|
+
schema: createSchema,
|
|
825
|
+
response: () => ({ ok: true }),
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
|
|
831
|
+
method: 'POST',
|
|
832
|
+
body: JSON.stringify({ title: 'A' }),
|
|
833
|
+
headers: { 'content-type': 'application/json' },
|
|
834
|
+
}))
|
|
835
|
+
|
|
836
|
+
expect(res.status).toBe(403)
|
|
837
|
+
expect(guardValidate).toHaveBeenCalledWith(expect.objectContaining({
|
|
838
|
+
resourceKind: 'example.todo',
|
|
839
|
+
resourceId: null,
|
|
840
|
+
operation: 'create',
|
|
841
|
+
mutationPayload: expect.objectContaining({ title: 'A' }),
|
|
842
|
+
}))
|
|
843
|
+
expect(commandBus.execute).not.toHaveBeenCalled()
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
it('POST command route merges guard modifiedPayload and runs afterSuccess with the command result id', async () => {
|
|
847
|
+
commandBus.execute.mockResolvedValue({ result: { id: 'cmd-created-1' }, logEntry: { id: 'log-1' } })
|
|
848
|
+
const guardAfterSuccess = jest.fn(async () => {})
|
|
849
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
850
|
+
id: 'example.rewrite-command-create',
|
|
851
|
+
targetEntity: 'example.todo',
|
|
852
|
+
operations: ['create'],
|
|
853
|
+
validate: async (_input: any) => ({ ok: true, modifiedPayload: { title: 'FROM-GUARD' }, shouldRunAfterSuccess: true }),
|
|
854
|
+
afterSuccess: guardAfterSuccess,
|
|
855
|
+
}] }])
|
|
856
|
+
const commandRoute = makeCrudRoute({
|
|
857
|
+
metadata: { POST: { requireAuth: true } },
|
|
858
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
859
|
+
indexer: { entityType: 'example.todo' },
|
|
860
|
+
actions: {
|
|
861
|
+
create: {
|
|
862
|
+
commandId: 'example.todo.create',
|
|
863
|
+
schema: createSchema,
|
|
864
|
+
response: () => ({ ok: true }),
|
|
865
|
+
},
|
|
866
|
+
},
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
|
|
870
|
+
method: 'POST',
|
|
871
|
+
body: JSON.stringify({ title: 'A' }),
|
|
872
|
+
headers: { 'content-type': 'application/json' },
|
|
873
|
+
}))
|
|
874
|
+
|
|
875
|
+
expect(res.status).toBe(201)
|
|
876
|
+
expect(commandBus.execute).toHaveBeenCalledWith('example.todo.create', expect.objectContaining({
|
|
877
|
+
input: expect.objectContaining({ title: 'FROM-GUARD' }),
|
|
878
|
+
}))
|
|
879
|
+
expect(guardAfterSuccess).toHaveBeenCalledWith(expect.objectContaining({
|
|
880
|
+
resourceId: 'cmd-created-1',
|
|
881
|
+
operation: 'create',
|
|
882
|
+
}))
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
it('POST command route falls back to the response payload id for guard afterSuccess', async () => {
|
|
886
|
+
commandBus.execute.mockResolvedValue({ result: { lineId: 'line-42' }, logEntry: { id: 'log-1' } })
|
|
887
|
+
const guardAfterSuccess = jest.fn(async () => {})
|
|
888
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
889
|
+
id: 'example.after-command-create',
|
|
890
|
+
targetEntity: 'example.todo',
|
|
891
|
+
operations: ['create'],
|
|
892
|
+
validate: async (_input: any) => ({ ok: true, shouldRunAfterSuccess: true }),
|
|
893
|
+
afterSuccess: guardAfterSuccess,
|
|
894
|
+
}] }])
|
|
895
|
+
const commandRoute = makeCrudRoute({
|
|
896
|
+
metadata: { POST: { requireAuth: true } },
|
|
897
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
898
|
+
indexer: { entityType: 'example.todo' },
|
|
899
|
+
actions: {
|
|
900
|
+
create: {
|
|
901
|
+
commandId: 'example.todo.create',
|
|
902
|
+
schema: createSchema,
|
|
903
|
+
response: ({ result }: any) => ({ id: result.lineId }),
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
|
|
909
|
+
method: 'POST',
|
|
910
|
+
body: JSON.stringify({ title: 'A' }),
|
|
911
|
+
headers: { 'content-type': 'application/json' },
|
|
912
|
+
}))
|
|
913
|
+
|
|
914
|
+
expect(res.status).toBe(201)
|
|
915
|
+
expect(guardAfterSuccess).toHaveBeenCalledWith(expect.objectContaining({
|
|
916
|
+
resourceId: 'line-42',
|
|
917
|
+
operation: 'create',
|
|
918
|
+
}))
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
// Commands whose mapInput wraps the payload (e.g. `{ body }`) null the factory
|
|
922
|
+
// candidateId and thereby OPT OUT of row-level mutation guards, leaving the
|
|
923
|
+
// command-level optimistic-lock check as the sole guard — a documented contract
|
|
924
|
+
// (apps/docs/docs/framework/data-integrity/concurrency-locking.mdx) that sales
|
|
925
|
+
// line/adjustment routes rely on.
|
|
926
|
+
it('PUT command route without a top-level id keeps the documented row-level guard opt-out', async () => {
|
|
927
|
+
crudMutationGuardService = {
|
|
928
|
+
validateMutation: jest.fn().mockResolvedValue({ ok: true, shouldRunAfterSuccess: false }),
|
|
929
|
+
afterMutationSuccess: jest.fn().mockResolvedValue(undefined),
|
|
930
|
+
}
|
|
931
|
+
const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 409, message: 'must not run' }))
|
|
932
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
933
|
+
id: 'example.idless-update-opt-out',
|
|
934
|
+
targetEntity: 'example.todo',
|
|
935
|
+
operations: ['update'],
|
|
936
|
+
validate: guardValidate,
|
|
937
|
+
}] }])
|
|
938
|
+
const commandRoute = makeCrudRoute({
|
|
939
|
+
metadata: { PUT: { requireAuth: true } },
|
|
940
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
941
|
+
indexer: { entityType: 'example.todo' },
|
|
942
|
+
actions: {
|
|
943
|
+
update: {
|
|
944
|
+
commandId: 'example.todo.update',
|
|
945
|
+
schema: z.object({ title: z.string() }),
|
|
946
|
+
mapInput: ({ parsed }: any) => ({ body: parsed }),
|
|
947
|
+
response: () => ({ ok: true }),
|
|
948
|
+
},
|
|
949
|
+
},
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
const res = await commandRoute.PUT(new Request('http://x/api/example/todos/command', {
|
|
953
|
+
method: 'PUT',
|
|
954
|
+
body: JSON.stringify({ title: 'nested id shape' }),
|
|
955
|
+
headers: { 'content-type': 'application/json' },
|
|
956
|
+
}))
|
|
957
|
+
|
|
958
|
+
expect(res.status).toBe(200)
|
|
959
|
+
expect(guardValidate).not.toHaveBeenCalled()
|
|
960
|
+
expect(crudMutationGuardService.validateMutation).not.toHaveBeenCalled()
|
|
961
|
+
expect(commandBus.execute).toHaveBeenCalledWith('example.todo.update', expect.anything())
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
it('DELETE command route without any id keeps the documented row-level guard opt-out', async () => {
|
|
965
|
+
const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 403, message: 'must not run' }))
|
|
966
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
967
|
+
id: 'example.idless-delete-opt-out',
|
|
968
|
+
targetEntity: 'example.todo',
|
|
969
|
+
operations: ['delete'],
|
|
970
|
+
validate: guardValidate,
|
|
971
|
+
}] }])
|
|
972
|
+
const commandRoute = makeCrudRoute({
|
|
973
|
+
metadata: { DELETE: { requireAuth: true } },
|
|
974
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
975
|
+
indexer: { entityType: 'example.todo' },
|
|
976
|
+
actions: {
|
|
977
|
+
delete: {
|
|
978
|
+
commandId: 'example.todo.delete',
|
|
979
|
+
schema: z.any(),
|
|
980
|
+
response: () => ({ ok: true }),
|
|
981
|
+
},
|
|
982
|
+
},
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
const res = await commandRoute.DELETE(new Request('http://x/api/example/todos/command', {
|
|
986
|
+
method: 'DELETE',
|
|
987
|
+
body: JSON.stringify({}),
|
|
988
|
+
headers: { 'content-type': 'application/json' },
|
|
989
|
+
}))
|
|
990
|
+
|
|
991
|
+
expect(res.status).toBe(200)
|
|
992
|
+
expect(guardValidate).not.toHaveBeenCalled()
|
|
993
|
+
expect(commandBus.execute).toHaveBeenCalledWith('example.todo.delete', expect.anything())
|
|
994
|
+
})
|
|
995
|
+
|
|
724
996
|
it('POST is blocked by interceptor before hook', async () => {
|
|
725
997
|
registerApiInterceptors([
|
|
726
998
|
{
|
|
@@ -784,6 +1056,85 @@ describe('CRUD Factory', () => {
|
|
|
784
1056
|
const body = await res.json()
|
|
785
1057
|
expect(body._interceptor).toEqual({ ok: true, count: 1 })
|
|
786
1058
|
})
|
|
1059
|
+
|
|
1060
|
+
// The command DELETE path used to hand before-interceptors the body only, so a
|
|
1061
|
+
// guard reading `?id=` saw nothing to object to and the delete went through
|
|
1062
|
+
// with a 200 (issue #4842).
|
|
1063
|
+
it('DELETE command route passes the query id to interceptor before hooks', async () => {
|
|
1064
|
+
const lockedId = '123e4567-e89b-12d3-a456-426614174010'
|
|
1065
|
+
const seenQueries: Array<Record<string, unknown> | undefined> = []
|
|
1066
|
+
registerApiInterceptors([
|
|
1067
|
+
{
|
|
1068
|
+
moduleId: 'example',
|
|
1069
|
+
interceptors: [
|
|
1070
|
+
{
|
|
1071
|
+
id: 'example.block-locked-delete',
|
|
1072
|
+
targetRoute: 'example/todos/command',
|
|
1073
|
+
methods: ['DELETE'],
|
|
1074
|
+
async before(request) {
|
|
1075
|
+
seenQueries.push(request.query)
|
|
1076
|
+
const queryId = request.query?.id
|
|
1077
|
+
if (typeof queryId === 'string' && queryId === lockedId) {
|
|
1078
|
+
return { ok: false, statusCode: 409, message: 'Record is locked' }
|
|
1079
|
+
}
|
|
1080
|
+
return { ok: true }
|
|
1081
|
+
},
|
|
1082
|
+
},
|
|
1083
|
+
],
|
|
1084
|
+
},
|
|
1085
|
+
])
|
|
1086
|
+
const commandRoute = makeCrudRoute({
|
|
1087
|
+
metadata: { DELETE: { requireAuth: true } },
|
|
1088
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
1089
|
+
indexer: { entityType: 'example.todo' },
|
|
1090
|
+
actions: {
|
|
1091
|
+
delete: {
|
|
1092
|
+
commandId: 'example.todo.delete',
|
|
1093
|
+
schema: z.any(),
|
|
1094
|
+
response: () => ({ ok: true }),
|
|
1095
|
+
},
|
|
1096
|
+
},
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
const res = await commandRoute.DELETE(new Request(`http://x/api/example/todos/command?id=${lockedId}`, {
|
|
1100
|
+
method: 'DELETE',
|
|
1101
|
+
body: JSON.stringify({}),
|
|
1102
|
+
headers: { 'content-type': 'application/json' },
|
|
1103
|
+
}))
|
|
1104
|
+
|
|
1105
|
+
expect(res.status).toBe(409)
|
|
1106
|
+
expect(await res.json()).toMatchObject({ error: 'Record is locked' })
|
|
1107
|
+
expect(seenQueries).toEqual([{ id: lockedId }])
|
|
1108
|
+
expect(commandBus.execute).not.toHaveBeenCalled()
|
|
1109
|
+
})
|
|
1110
|
+
|
|
1111
|
+
it('DELETE route passes the whole query string to interceptor before hooks', async () => {
|
|
1112
|
+
const created = em.create(Todo, { title: 'Z', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
|
|
1113
|
+
created.id = '123e4567-e89b-12d3-a456-426614174011'
|
|
1114
|
+
await em.persist(created).flush()
|
|
1115
|
+
let seenQuery: Record<string, unknown> | undefined
|
|
1116
|
+
registerApiInterceptors([
|
|
1117
|
+
{
|
|
1118
|
+
moduleId: 'example',
|
|
1119
|
+
interceptors: [
|
|
1120
|
+
{
|
|
1121
|
+
id: 'example.capture-delete-query',
|
|
1122
|
+
targetRoute: 'example/todos',
|
|
1123
|
+
methods: ['DELETE'],
|
|
1124
|
+
async before(request) {
|
|
1125
|
+
seenQuery = request.query
|
|
1126
|
+
return { ok: true }
|
|
1127
|
+
},
|
|
1128
|
+
},
|
|
1129
|
+
],
|
|
1130
|
+
},
|
|
1131
|
+
])
|
|
1132
|
+
|
|
1133
|
+
const res = await route.DELETE(new Request(`http://x/api/example/todos?id=${created.id}&reason=cleanup`, { method: 'DELETE' }))
|
|
1134
|
+
|
|
1135
|
+
expect(res.status).toBe(200)
|
|
1136
|
+
expect(seenQuery).toEqual({ id: created.id, reason: 'cleanup' })
|
|
1137
|
+
})
|
|
787
1138
|
})
|
|
788
1139
|
|
|
789
1140
|
describe('CRUD Factory — optimistic-lock auto-registration', () => {
|
|
@@ -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
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
isCrudCacheEnabled,
|
|
58
58
|
normalizeIdentifierValue,
|
|
59
59
|
normalizeTagSegment,
|
|
60
|
+
pickFirstIdentifier,
|
|
60
61
|
resolveCrudCache,
|
|
61
62
|
} from './cache'
|
|
62
63
|
import { deriveCrudSegmentTag } from './cache-stats'
|
|
@@ -72,6 +73,7 @@ import { parseExtensionHeaders } from '../umes/extension-headers'
|
|
|
72
73
|
import { createGenericOptimisticLockReader } from './optimistic-lock'
|
|
73
74
|
import { registerOptimisticLockReaderIfAbsent } from './optimistic-lock-store'
|
|
74
75
|
import { createLogger } from '../logger'
|
|
76
|
+
import { isTransientDbError } from '../db/pg-errors'
|
|
75
77
|
|
|
76
78
|
type RbacServiceLike = {
|
|
77
79
|
getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>
|
|
@@ -250,6 +252,7 @@ const DEFAULT_EXPORT_FORMATS: CrudExportFormat[] = ['csv', 'json', 'xml', 'markd
|
|
|
250
252
|
const DEFAULT_EXPORT_BATCH_SIZE = 1000
|
|
251
253
|
const MIN_EXPORT_BATCH_SIZE = 100
|
|
252
254
|
const MAX_EXPORT_BATCH_SIZE = 10000
|
|
255
|
+
const EXPORT_MAX_PAGES = 1000
|
|
253
256
|
|
|
254
257
|
type ColumnResolver = {
|
|
255
258
|
field: string
|
|
@@ -554,6 +557,18 @@ function handleError(err: unknown): Response {
|
|
|
554
557
|
if (err instanceof Response) return err
|
|
555
558
|
if (isCrudHttpError(err)) return json(err.body, { status: err.status })
|
|
556
559
|
if (err instanceof z.ZodError) return json({ error: 'Invalid input', details: err.issues }, { status: 400 })
|
|
560
|
+
if (isTransientDbError(err)) {
|
|
561
|
+
// Transient DB unavailability (pool exhausted, `max_connections` reached, DB
|
|
562
|
+
// restarting) is retryable — surface a 503 with a Retry-After hint instead of
|
|
563
|
+
// a generic 500 so clients back off and retry once the DB recovers.
|
|
564
|
+
logger.warn('Transient DB failure during CRUD handler', {
|
|
565
|
+
message: err instanceof Error ? err.message : undefined,
|
|
566
|
+
})
|
|
567
|
+
return json(
|
|
568
|
+
{ error: 'Service temporarily unavailable' },
|
|
569
|
+
{ status: 503, headers: { 'Retry-After': '2' } },
|
|
570
|
+
)
|
|
571
|
+
}
|
|
557
572
|
|
|
558
573
|
const message = err instanceof Error ? err.message : undefined
|
|
559
574
|
const stack = err instanceof Error ? err.stack : undefined
|
|
@@ -915,6 +930,12 @@ function buildCrudCacheKey(
|
|
|
915
930
|
`tenant:${normalizeTagSegment(ctx.auth?.tenantId ?? null)}`,
|
|
916
931
|
`selectedOrg:${normalizeTagSegment(ctx.selectedOrganizationId ?? null)}`,
|
|
917
932
|
`scope:${scopeSegment}`,
|
|
933
|
+
// List payloads can vary per caller identity beyond tenant/org scope:
|
|
934
|
+
// buildFilters may narrow by ctx.auth (e.g. ?mine=true), before-interceptor
|
|
935
|
+
// query rewrites are feature-gated per user, and afterList/after-interceptor
|
|
936
|
+
// output is embedded in the stored payload — so entries MUST be partitioned
|
|
937
|
+
// per actor (API key or user), never shared across identities.
|
|
938
|
+
`user:${normalizeTagSegment((ctx.auth?.keyId ?? ctx.auth?.sub) ?? null)}`,
|
|
918
939
|
`query:${serializeSearchParams(url.searchParams)}`,
|
|
919
940
|
]
|
|
920
941
|
// The cached list payload already embeds enricher output (enrichment runs before
|
|
@@ -1786,13 +1807,17 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1786
1807
|
const initialExportItems = exportFullRequested
|
|
1787
1808
|
? rawItems.map(normalizeFullRecordForExport)
|
|
1788
1809
|
: transformedItems
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1810
|
+
const exportItems = [...initialExportItems]
|
|
1811
|
+
const exportPageSizeNumber = typeof page.pageSize === 'number' ? page.pageSize : exportPageSize
|
|
1812
|
+
// Short-page termination: `total` is a display value, not a loop bound — it can
|
|
1813
|
+
// under-report (capped counts) or drift while rows are inserted/deleted mid-export.
|
|
1814
|
+
// Keep fetching while pages come back full; fail closed at the page ceiling rather
|
|
1815
|
+
// than serializing a partial export.
|
|
1816
|
+
if (rawItems.length >= exportPageSizeNumber) {
|
|
1792
1817
|
const queryBase: any = { ...queryOpts }
|
|
1793
1818
|
delete queryBase.page
|
|
1794
1819
|
let nextPage = 2
|
|
1795
|
-
|
|
1820
|
+
for (;;) {
|
|
1796
1821
|
profiler.mark('export_next_page_request', { page: nextPage })
|
|
1797
1822
|
const nextRes = await qe.query(opts.list.entityId as any, {
|
|
1798
1823
|
...queryBase,
|
|
@@ -1807,7 +1832,10 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1807
1832
|
? nextItemsRaw.map(normalizeFullRecordForExport)
|
|
1808
1833
|
: nextTransformed
|
|
1809
1834
|
exportItems.push(...nextExportItems)
|
|
1810
|
-
if (
|
|
1835
|
+
if (nextItemsRaw.length < exportPageSizeNumber) break
|
|
1836
|
+
if (nextPage >= EXPORT_MAX_PAGES) {
|
|
1837
|
+
throw new Error(`[internal] export exceeded ${EXPORT_MAX_PAGES} pages; refusing to return a partial export`)
|
|
1838
|
+
}
|
|
1811
1839
|
nextPage += 1
|
|
1812
1840
|
}
|
|
1813
1841
|
}
|
|
@@ -2140,6 +2168,31 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2140
2168
|
}
|
|
2141
2169
|
}
|
|
2142
2170
|
|
|
2171
|
+
// Mutation guard registry — command path (mirrors the direct create branch)
|
|
2172
|
+
const createCmdUserFeatures = await resolveUserFeatures(ctx)
|
|
2173
|
+
const { allGuards: createCmdAllGuards } = collectAndRunGuards(ctx.container)
|
|
2174
|
+
let createCmdGuardAfterCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }> = []
|
|
2175
|
+
if (createCmdAllGuards.length && ctx.auth.tenantId) {
|
|
2176
|
+
const guardResult = await runMutationGuards(createCmdAllGuards, {
|
|
2177
|
+
tenantId: ctx.auth.tenantId,
|
|
2178
|
+
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
2179
|
+
userId: ctx.auth.sub,
|
|
2180
|
+
resourceKind,
|
|
2181
|
+
resourceId: null,
|
|
2182
|
+
operation: 'create',
|
|
2183
|
+
requestMethod: request.method,
|
|
2184
|
+
requestHeaders: request.headers,
|
|
2185
|
+
mutationPayload: input && typeof input === 'object' ? (input as Record<string, unknown>) : null,
|
|
2186
|
+
}, { userFeatures: createCmdUserFeatures ?? [] })
|
|
2187
|
+
if (!guardResult.ok) {
|
|
2188
|
+
return json(guardResult.errorBody ?? { error: 'Operation blocked by guard' }, { status: guardResult.errorStatus ?? 422 })
|
|
2189
|
+
}
|
|
2190
|
+
if (guardResult.modifiedPayload && typeof input === 'object' && input) {
|
|
2191
|
+
input = { ...input as Record<string, unknown>, ...guardResult.modifiedPayload }
|
|
2192
|
+
}
|
|
2193
|
+
createCmdGuardAfterCallbacks = guardResult.afterSuccessCallbacks
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2143
2196
|
const baseMetadata: CommandLogMetadata = {
|
|
2144
2197
|
tenantId: ctx.auth?.tenantId ?? null,
|
|
2145
2198
|
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
@@ -2182,6 +2235,22 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2182
2235
|
const status = action.status ?? 201
|
|
2183
2236
|
const response = json(resolvedPayload, { status })
|
|
2184
2237
|
attachOperationHeader(response, logEntry)
|
|
2238
|
+
const commandResultId = pickFirstIdentifier(
|
|
2239
|
+
(result as Record<string, unknown> | null | undefined)?.id,
|
|
2240
|
+
(resolvedPayload as Record<string, unknown> | null | undefined)?.id,
|
|
2241
|
+
)
|
|
2242
|
+
if (createCmdGuardAfterCallbacks.length && ctx.auth.tenantId && commandResultId) {
|
|
2243
|
+
await runGuardAfterSuccessCallbacks(createCmdGuardAfterCallbacks, {
|
|
2244
|
+
tenantId: ctx.auth.tenantId,
|
|
2245
|
+
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
2246
|
+
userId: ctx.auth.sub,
|
|
2247
|
+
resourceKind,
|
|
2248
|
+
resourceId: commandResultId,
|
|
2249
|
+
operation: 'create',
|
|
2250
|
+
requestMethod: request.method,
|
|
2251
|
+
requestHeaders: request.headers,
|
|
2252
|
+
})
|
|
2253
|
+
}
|
|
2185
2254
|
// Note: side effects (events + indexing) are already flushed by CommandBus.execute()
|
|
2186
2255
|
// via flushCrudSideEffects(). Calling markCommandResultForIndexing here would cause
|
|
2187
2256
|
// duplicate event emissions.
|
|
@@ -2285,6 +2354,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2285
2354
|
organizationId: targetOrgId,
|
|
2286
2355
|
tenantId: writeTenantId,
|
|
2287
2356
|
values,
|
|
2357
|
+
notify: false,
|
|
2288
2358
|
})
|
|
2289
2359
|
}
|
|
2290
2360
|
}
|
|
@@ -2422,6 +2492,10 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2422
2492
|
const updateUserFeatures = await resolveUserFeatures(ctx)
|
|
2423
2493
|
const { allGuards: updateAllGuards } = collectAndRunGuards(ctx.container)
|
|
2424
2494
|
let cmdUpdateGuardAfterCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }> = []
|
|
2495
|
+
// Commands whose mapInput wraps the payload (e.g. `{ body }`) intentionally
|
|
2496
|
+
// null candidateId and OPT OUT of row-level guards, leaving the command-level
|
|
2497
|
+
// optimistic-lock check as the sole guard — a documented contract, see
|
|
2498
|
+
// apps/docs/docs/framework/data-integrity/concurrency-locking.mdx.
|
|
2425
2499
|
if (updateAllGuards.length && ctx.auth.tenantId && candidateId) {
|
|
2426
2500
|
const guardResult = await runMutationGuards(updateAllGuards, {
|
|
2427
2501
|
tenantId: ctx.auth.tenantId,
|
|
@@ -2618,6 +2692,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2618
2692
|
organizationId: targetOrgId,
|
|
2619
2693
|
tenantId: writeTenantId,
|
|
2620
2694
|
values,
|
|
2695
|
+
notify: false,
|
|
2621
2696
|
})
|
|
2622
2697
|
}
|
|
2623
2698
|
}
|
|
@@ -2721,6 +2796,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2721
2796
|
request,
|
|
2722
2797
|
method: 'DELETE',
|
|
2723
2798
|
body: interceptorInput,
|
|
2799
|
+
query: raw.query,
|
|
2724
2800
|
})
|
|
2725
2801
|
if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
|
|
2726
2802
|
interceptorRequestPayload = beforeInterceptors.requestPayload
|
|
@@ -2845,7 +2921,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2845
2921
|
request,
|
|
2846
2922
|
method: 'DELETE',
|
|
2847
2923
|
body: idFrom === 'query' ? undefined : ({ id } as Record<string, unknown>),
|
|
2848
|
-
query: idFrom === 'query' ? (
|
|
2924
|
+
query: idFrom === 'query' ? Object.fromEntries(url.searchParams.entries()) : undefined,
|
|
2849
2925
|
})
|
|
2850
2926
|
if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
|
|
2851
2927
|
interceptorRequestPayload = beforeInterceptors.requestPayload
|