@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.
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.10.1-develop.7690.1.9d41ec4115";
1
+ const APP_VERSION = "0.10.1-develop.7699.1.5236fabda0";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -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.7690.1.9d41ec4115';\nexport const appVersion = APP_VERSION;\n"],
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.7690.1.9d41ec4115",
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.7690.1.9d41ec4115",
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
@@ -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 !== undefined) return cached as AuthContext
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
@@ -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
- ? ({} as Where<any>)
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
- ? ({} as Where<any>)
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