@open-mercato/shared 0.10.1-develop.7690.1.9d41ec4115 → 0.10.1-develop.7699.1.5236fabda0
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/dist/lib/auth/server.js +19 -9
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/crud/factory.js +8 -2
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +110 -0
- package/src/lib/auth/server.ts +20 -10
- package/src/lib/crud/__tests__/crud-factory.test.ts +73 -1
- package/src/lib/crud/factory.ts +14 -2
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.10.1-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.10.1-develop.7699.1.5236fabda0';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7699.1.5236fabda0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"@mikro-orm/core": "^7.1.14",
|
|
127
127
|
"@mikro-orm/decorators": "^7.1.14",
|
|
128
128
|
"@mikro-orm/postgresql": "^7.1.14",
|
|
129
|
-
"@open-mercato/cache": "0.10.1-develop.
|
|
129
|
+
"@open-mercato/cache": "0.10.1-develop.7699.1.5236fabda0",
|
|
130
130
|
"@types/html-to-text": "^9.0.4",
|
|
131
131
|
"@types/sanitize-html": "^2.16.1",
|
|
132
132
|
"dotenv": "^17.4.2",
|
|
@@ -20,6 +20,10 @@ jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
|
20
20
|
createRequestContainer: (...args: unknown[]) => createRequestContainer(...args),
|
|
21
21
|
}))
|
|
22
22
|
|
|
23
|
+
jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
|
|
24
|
+
findOneWithDecryption: (_manager: unknown, ...args: unknown[]) => emFindOne(...args),
|
|
25
|
+
}))
|
|
26
|
+
|
|
23
27
|
jest.mock('@open-mercato/core/modules/auth/lib/sessionIntegrity', () => ({
|
|
24
28
|
resolveCanonicalStaffAuthContext: jest.fn(async (_em: unknown, auth: unknown) => auth),
|
|
25
29
|
}))
|
|
@@ -234,6 +238,7 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
|
|
|
234
238
|
lastUsedAt: null,
|
|
235
239
|
})
|
|
236
240
|
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
241
|
+
if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
|
|
237
242
|
if (where.id === 'creator-1') {
|
|
238
243
|
return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
239
244
|
}
|
|
@@ -267,6 +272,7 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
|
|
|
267
272
|
lastUsedAt: null,
|
|
268
273
|
})
|
|
269
274
|
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
275
|
+
if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
|
|
270
276
|
if (where.id === 'creator-1') {
|
|
271
277
|
return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'org-2' }
|
|
272
278
|
}
|
|
@@ -295,6 +301,7 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
|
|
|
295
301
|
lastUsedAt: null,
|
|
296
302
|
})
|
|
297
303
|
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
304
|
+
if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
|
|
298
305
|
if (where.id === 'session-user-1') {
|
|
299
306
|
return { id: 'session-user-1', tenantId: 'tenant-1', organizationId: 'user-org' }
|
|
300
307
|
}
|
|
@@ -322,6 +329,10 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
|
|
|
322
329
|
expiresAt: null,
|
|
323
330
|
lastUsedAt: null,
|
|
324
331
|
})
|
|
332
|
+
emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
333
|
+
if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
|
|
334
|
+
return null
|
|
335
|
+
})
|
|
325
336
|
|
|
326
337
|
await expect(
|
|
327
338
|
getAuthFromRequest(buildRequest('malformed-session-secret')),
|
|
@@ -367,6 +378,105 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
|
|
|
367
378
|
expect(findApiKeyBySecret).toHaveBeenCalledTimes(2)
|
|
368
379
|
})
|
|
369
380
|
|
|
381
|
+
describe.each(['unbound', 'creator-bound', 'session-bound'] as const)('%s tenant liveness', (binding) => {
|
|
382
|
+
let tenant: { id: string; isActive: boolean; deletedAt: Date | null } | null
|
|
383
|
+
|
|
384
|
+
beforeEach(async () => {
|
|
385
|
+
const [{ Tenant }, { User }] = await Promise.all([
|
|
386
|
+
import('@open-mercato/core/modules/directory/data/entities'),
|
|
387
|
+
import('@open-mercato/core/modules/auth/data/entities'),
|
|
388
|
+
])
|
|
389
|
+
tenant = { id: 'tenant-1', isActive: true, deletedAt: null }
|
|
390
|
+
findApiKeyBySecret.mockResolvedValue({
|
|
391
|
+
id: 'key-tenant-liveness',
|
|
392
|
+
name: 'tenant liveness',
|
|
393
|
+
tenantId: 'tenant-1',
|
|
394
|
+
organizationId: null,
|
|
395
|
+
rolesJson: [],
|
|
396
|
+
sessionToken: binding === 'session-bound' ? 'session-marker' : null,
|
|
397
|
+
sessionUserId: binding === 'session-bound' ? 'key-owner' : null,
|
|
398
|
+
sessionSecretEncrypted: null,
|
|
399
|
+
opencodeSessionId: null,
|
|
400
|
+
createdBy: binding === 'unbound' ? null : 'key-owner',
|
|
401
|
+
expiresAt: null,
|
|
402
|
+
lastUsedAt: null,
|
|
403
|
+
})
|
|
404
|
+
emFindOne.mockImplementation(async (entity: unknown, where: Record<string, unknown>) => {
|
|
405
|
+
if (entity === Tenant) {
|
|
406
|
+
return tenant && where.id === tenant.id && where.isActive === tenant.isActive && where.deletedAt === tenant.deletedAt
|
|
407
|
+
? tenant
|
|
408
|
+
: null
|
|
409
|
+
}
|
|
410
|
+
if (entity === User && where.id === 'key-owner') {
|
|
411
|
+
return { id: 'key-owner', tenantId: 'tenant-1', organizationId: null }
|
|
412
|
+
}
|
|
413
|
+
return null
|
|
414
|
+
})
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
function setTenantState(state: 'active' | 'inactive' | 'deleted' | 'missing') {
|
|
418
|
+
tenant = state === 'missing'
|
|
419
|
+
? null
|
|
420
|
+
: { id: 'tenant-1', isActive: state !== 'inactive', deletedAt: state === 'deleted' ? new Date() : null }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
it.each(['active', 'inactive', 'deleted', 'missing'] as const)('authenticates only an active existing tenant (%s)', async (state) => {
|
|
424
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
425
|
+
setTenantState(state)
|
|
426
|
+
|
|
427
|
+
const auth = await getAuthFromRequest(buildRequest('tenant-liveness-secret'))
|
|
428
|
+
|
|
429
|
+
if (state === 'active') {
|
|
430
|
+
expect(auth).toMatchObject({ keyId: 'key-tenant-liveness', tenantId: 'tenant-1' })
|
|
431
|
+
} else {
|
|
432
|
+
expect(auth).toBeNull()
|
|
433
|
+
expect(emPersist).not.toHaveBeenCalled()
|
|
434
|
+
}
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it.each(['inactive', 'deleted', 'missing'] as const)('rejects a cached key immediately after its tenant becomes %s', async (state) => {
|
|
438
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
439
|
+
await expect(getAuthFromRequest(buildRequest('tenant-liveness-secret'))).resolves.toMatchObject({
|
|
440
|
+
keyId: 'key-tenant-liveness',
|
|
441
|
+
})
|
|
442
|
+
setTenantState(state)
|
|
443
|
+
|
|
444
|
+
await expect(getAuthFromRequest(buildRequest('tenant-liveness-secret'))).resolves.toBeNull()
|
|
445
|
+
|
|
446
|
+
expect(findApiKeyBySecret).toHaveBeenCalledTimes(1)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('rechecks an active tenant while retaining the cached secret lookup', async () => {
|
|
450
|
+
const { Tenant } = await import('@open-mercato/core/modules/directory/data/entities')
|
|
451
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
452
|
+
const first = await getAuthFromRequest(buildRequest('tenant-liveness-secret'))
|
|
453
|
+
|
|
454
|
+
await expect(getAuthFromRequest(buildRequest('tenant-liveness-secret'))).resolves.toEqual(first)
|
|
455
|
+
|
|
456
|
+
expect(first).toMatchObject({ keyId: 'key-tenant-liveness' })
|
|
457
|
+
expect(findApiKeyBySecret).toHaveBeenCalledTimes(1)
|
|
458
|
+
expect(emFindOne).toHaveBeenCalledWith(Tenant, { id: 'tenant-1', deletedAt: null, isActive: true })
|
|
459
|
+
expect(emFindOne.mock.calls.filter((call) => call[0] === Tenant)).toHaveLength(2)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('reports cached tenant-check database unavailability without poisoning the cache', async () => {
|
|
463
|
+
const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
|
|
464
|
+
await expect(resolveAuthFromRequestDetailed(buildRequest('tenant-liveness-secret'))).resolves.toMatchObject({
|
|
465
|
+
status: 'authenticated',
|
|
466
|
+
})
|
|
467
|
+
emFindOne.mockRejectedValueOnce(Object.assign(new Error('connection unavailable'), { code: '53300' }))
|
|
468
|
+
|
|
469
|
+
await expect(resolveAuthFromRequestDetailed(buildRequest('tenant-liveness-secret'))).resolves.toEqual({
|
|
470
|
+
auth: null,
|
|
471
|
+
status: 'error',
|
|
472
|
+
})
|
|
473
|
+
await expect(resolveAuthFromRequestDetailed(buildRequest('tenant-liveness-secret'))).resolves.toMatchObject({
|
|
474
|
+
status: 'authenticated',
|
|
475
|
+
})
|
|
476
|
+
expect(findApiKeyBySecret).toHaveBeenCalledTimes(1)
|
|
477
|
+
})
|
|
478
|
+
})
|
|
479
|
+
|
|
370
480
|
describe('super-admin bit stays bounded by the effective key scope', () => {
|
|
371
481
|
async function resolveWithSuperAdminRole(input: {
|
|
372
482
|
secret: string
|
package/src/lib/auth/server.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { isMfaPendingJwtPayload, verifyJwt } from './jwt'
|
|
|
4
4
|
import { isMfaPendingAccessAllowed } from './mfaPendingAccess'
|
|
5
5
|
import { getSharedApiKeyAuthCache } from './apiKeyAuthCache'
|
|
6
6
|
import { isTransientDbError } from '@open-mercato/shared/lib/db/pg-errors'
|
|
7
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
7
8
|
|
|
8
9
|
function readRequestPathname(req: Request): string | null {
|
|
9
10
|
try {
|
|
@@ -190,21 +191,37 @@ function applySuperAdminScope(
|
|
|
190
191
|
async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
|
|
191
192
|
if (!secret) return null
|
|
192
193
|
const cache = getSharedApiKeyAuthCache()
|
|
193
|
-
const cached = cache.get(secret)
|
|
194
|
-
if (cached
|
|
194
|
+
const cached = cache.get(secret) as AuthContext | undefined
|
|
195
|
+
if (cached === null) return null
|
|
196
|
+
if (cached && !cached.tenantId) return cached
|
|
195
197
|
try {
|
|
196
198
|
const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
|
|
197
199
|
const container = await createRequestContainer()
|
|
198
200
|
const em = (container.resolve('em') as EntityManager)
|
|
201
|
+
const { Organization, Tenant } = await import('@open-mercato/core/modules/directory/data/entities')
|
|
202
|
+
if (cached?.tenantId) {
|
|
203
|
+
const tenant = await findOneWithDecryption(em, Tenant, { id: cached.tenantId, deletedAt: null, isActive: true })
|
|
204
|
+
if (!tenant) {
|
|
205
|
+
cache.setMiss(secret)
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
return cached
|
|
209
|
+
}
|
|
199
210
|
const { findApiKeyBySecret } = await import('@open-mercato/core/modules/api_keys/services/apiKeyService')
|
|
200
211
|
const { Role, RoleAcl, User } = await import('@open-mercato/core/modules/auth/data/entities')
|
|
201
|
-
const { Organization, Tenant } = await import('@open-mercato/core/modules/directory/data/entities')
|
|
202
212
|
|
|
203
213
|
const record = await findApiKeyBySecret(em, secret)
|
|
204
214
|
if (!record) {
|
|
205
215
|
cache.setMiss(secret)
|
|
206
216
|
return null
|
|
207
217
|
}
|
|
218
|
+
if (record.tenantId) {
|
|
219
|
+
const tenant = await findOneWithDecryption(em, Tenant, { id: record.tenantId, deletedAt: null, isActive: true })
|
|
220
|
+
if (!tenant) {
|
|
221
|
+
cache.setMiss(secret)
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
}
|
|
208
225
|
|
|
209
226
|
const roleIds = Array.isArray(record.rolesJson)
|
|
210
227
|
? record.rolesJson.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
|
@@ -288,13 +305,6 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
|
|
|
288
305
|
return null
|
|
289
306
|
}
|
|
290
307
|
} else {
|
|
291
|
-
if (record.tenantId) {
|
|
292
|
-
const tenant = await em.findOne(Tenant, { id: record.tenantId, deletedAt: null, isActive: true })
|
|
293
|
-
if (!tenant) {
|
|
294
|
-
cache.setMiss(secret)
|
|
295
|
-
return null
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
308
|
if (record.organizationId) {
|
|
299
309
|
const organization = await em.findOne(Organization, { id: record.organizationId, deletedAt: null, isActive: true })
|
|
300
310
|
if (!organization) {
|
|
@@ -12,7 +12,7 @@ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
|
12
12
|
resolveTranslations: async () => ({ t: mockTranslate, translate: mockTranslate }),
|
|
13
13
|
}))
|
|
14
14
|
|
|
15
|
-
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
15
|
+
import { makeCrudRoute, type CrudCtx } from '@open-mercato/shared/lib/crud/factory'
|
|
16
16
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
17
17
|
import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
|
|
18
18
|
import {
|
|
@@ -789,6 +789,41 @@ describe('CRUD Factory', () => {
|
|
|
789
789
|
expect(ids).toContain(otherTenant.id)
|
|
790
790
|
})
|
|
791
791
|
|
|
792
|
+
it('GET ORM fallback still applies buildFilters scope on a full export when omitAutomaticTenantOrgScope is set', async () => {
|
|
793
|
+
// Regression test for #7806 — the ORM-fallback counterpart of the query-engine
|
|
794
|
+
// case covered elsewhere in this file: a full export must not drop the route's
|
|
795
|
+
// own scope just because it owns visibility itself via omitAutomaticTenantOrgScope.
|
|
796
|
+
const scopedBuildFilters = jest.fn((_query: z.infer<typeof querySchema>, ctx: CrudCtx) => ({
|
|
797
|
+
organizationId: ctx.auth?.orgId ?? null,
|
|
798
|
+
}))
|
|
799
|
+
const fallbackRoute = makeCrudRoute({
|
|
800
|
+
metadata: { GET: { requireAuth: true } },
|
|
801
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
802
|
+
list: {
|
|
803
|
+
schema: querySchema,
|
|
804
|
+
omitAutomaticTenantOrgScope: true,
|
|
805
|
+
buildFilters: scopedBuildFilters,
|
|
806
|
+
},
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
const mine = em.create(Todo, { title: 'Mine', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
|
|
810
|
+
mine.id = '550e8400-e29b-41d4-a716-446655440033'
|
|
811
|
+
await em.persist(mine).flush()
|
|
812
|
+
const other = em.create(Todo, { title: 'Other', organizationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', tenantId: defaultTenantId }) as Rec
|
|
813
|
+
other.id = '550e8400-e29b-41d4-a716-446655440034'
|
|
814
|
+
await em.persist(other).flush()
|
|
815
|
+
|
|
816
|
+
const res = await fallbackRoute.GET(new Request('http://x/api/example/todos?format=json&exportScope=full'))
|
|
817
|
+
expect(res.status).toBe(200)
|
|
818
|
+
|
|
819
|
+
expect(scopedBuildFilters).toHaveBeenCalledWith({}, expect.objectContaining({
|
|
820
|
+
auth: expect.objectContaining({ orgId: defaultOrganizationId }),
|
|
821
|
+
}))
|
|
822
|
+
const text = await res.text()
|
|
823
|
+
expect(text).toContain(mine.id)
|
|
824
|
+
expect(text).not.toContain(other.id)
|
|
825
|
+
})
|
|
826
|
+
|
|
792
827
|
it('GET resolves function-form CSV headers and rows per request', async () => {
|
|
793
828
|
// Regression coverage for the additive `(query, ctx)` form of `ListConfig.csv`.
|
|
794
829
|
// A route whose export columns depend on per-request discovery (custom-field keys,
|
|
@@ -884,6 +919,43 @@ describe('CRUD Factory', () => {
|
|
|
884
919
|
})
|
|
885
920
|
})
|
|
886
921
|
|
|
922
|
+
it('GET still applies buildFilters scope on a full export when omitAutomaticTenantOrgScope is set', async () => {
|
|
923
|
+
// Regression test for #7806: a full export used to drop straight to `{}` as
|
|
924
|
+
// baseFilters, skipping buildFilters entirely. For a route that owns its own
|
|
925
|
+
// scope via omitAutomaticTenantOrgScope, neither the query engine nor the ORM
|
|
926
|
+
// fallback add a tenant/org predicate of their own, so that leaked every
|
|
927
|
+
// tenant's rows on `exportScope=full`.
|
|
928
|
+
const scopedBuildFilters = jest.fn((_query: z.infer<typeof querySchema>, ctx: CrudCtx) => ({
|
|
929
|
+
tenant_id: { $eq: ctx.auth?.tenantId ?? null },
|
|
930
|
+
}))
|
|
931
|
+
const scopedRoute = makeCrudRoute({
|
|
932
|
+
metadata: { GET: { requireAuth: true } },
|
|
933
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
934
|
+
indexer: { entityType: 'example.todo' },
|
|
935
|
+
list: {
|
|
936
|
+
schema: querySchema,
|
|
937
|
+
entityId: 'example.todo',
|
|
938
|
+
fields: ['id', 'title', 'is_done'],
|
|
939
|
+
sortFieldMap: { id: 'id' },
|
|
940
|
+
omitAutomaticTenantOrgScope: true,
|
|
941
|
+
buildFilters: scopedBuildFilters,
|
|
942
|
+
transformItem: (item: Record<string, unknown>) => ({ id: item.id, title: item.title }),
|
|
943
|
+
},
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
const res = await scopedRoute.GET(new Request('http://x/api/example/todos?format=json&exportScope=full'))
|
|
947
|
+
expect(res.status).toBe(200)
|
|
948
|
+
|
|
949
|
+
// buildFilters runs with an empty query — the caller's own UI filters are
|
|
950
|
+
// dropped on a full export — but still receives the real ctx, so its scope
|
|
951
|
+
// predicate (derived from ctx.auth.tenantId here) is preserved.
|
|
952
|
+
expect(scopedBuildFilters).toHaveBeenCalledWith({}, expect.objectContaining({
|
|
953
|
+
auth: expect.objectContaining({ tenantId: '123e4567-e89b-12d3-a456-426614174000' }),
|
|
954
|
+
}))
|
|
955
|
+
const lastQueryCall = queryEngine.query.mock.calls.at(-1)
|
|
956
|
+
expect(lastQueryCall?.[1]?.filters).toEqual({ tenant_id: { $eq: '123e4567-e89b-12d3-a456-426614174000' } })
|
|
957
|
+
})
|
|
958
|
+
|
|
887
959
|
describe('afterList hook ordering on the export paths', () => {
|
|
888
960
|
// Issue #5969: both export branches used to call serializeExport() before awaiting
|
|
889
961
|
// hooks.afterList, so a hook that patches values the base query cannot compute reached
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -1702,6 +1702,18 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1702
1702
|
const exportFullRequested = exportRequested && (exportScope === 'full' || parseBooleanToken((queryParams as any).full) === true)
|
|
1703
1703
|
profiler.mark('export_configured', { exportRequested, exportFullRequested })
|
|
1704
1704
|
|
|
1705
|
+
// A full export still owes the route's own visibility predicate. Routes with
|
|
1706
|
+
// `omitAutomaticTenantOrgScope` own their entire scope inside `buildFilters` — the
|
|
1707
|
+
// query engine/ORM fallback add no tenant/org predicate of their own for them — so
|
|
1708
|
+
// dropping to `{}` here would leak every tenant's rows (#7806). Re-run `buildFilters`
|
|
1709
|
+
// with an empty query so only its scope branches apply, not the caller's UI filters.
|
|
1710
|
+
const resolveFullExportBaseFilters = async () => {
|
|
1711
|
+
if (opts.list?.omitAutomaticTenantOrgScope && opts.list.buildFilters) {
|
|
1712
|
+
return opts.list.buildFilters({} as TList, ctx)
|
|
1713
|
+
}
|
|
1714
|
+
return {} as Where<Record<string, unknown>>
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1705
1717
|
const cacheEnabled =
|
|
1706
1718
|
isCrudCacheEnabled() && !exportRequested && !opts.list?.disableListCache
|
|
1707
1719
|
const cacheTimerStart = cacheEnabled && isCrudCacheDebugEnabled()
|
|
@@ -1913,7 +1925,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1913
1925
|
? { page: 1, pageSize: exportPageSize }
|
|
1914
1926
|
: { page: requestedPage, pageSize: requestedPageSize }
|
|
1915
1927
|
const baseFilters = exportFullRequested
|
|
1916
|
-
? (
|
|
1928
|
+
? await resolveFullExportBaseFilters()
|
|
1917
1929
|
: (opts.list.buildFilters ? await opts.list.buildFilters(validated as any, ctx) : ({} as Where<any>))
|
|
1918
1930
|
const filters = exportFullRequested
|
|
1919
1931
|
? baseFilters
|
|
@@ -2219,7 +2231,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2219
2231
|
return response
|
|
2220
2232
|
}
|
|
2221
2233
|
const fallbackBaseFilters = exportFullRequested
|
|
2222
|
-
? (
|
|
2234
|
+
? await resolveFullExportBaseFilters()
|
|
2223
2235
|
: (opts.list.buildFilters ? await opts.list.buildFilters(validated as any, ctx) : ({} as Where<any>))
|
|
2224
2236
|
const fallbackFilters = exportFullRequested
|
|
2225
2237
|
? fallbackBaseFilters
|