@open-mercato/shared 0.6.8-develop.7099.1.7ba2771d06 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -4
  3. package/dist/lib/auth/jwt.js +0 -6
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/organizationAccess.js +4 -7
  6. package/dist/lib/auth/organizationAccess.js.map +2 -2
  7. package/dist/lib/auth/server.js +7 -38
  8. package/dist/lib/auth/server.js.map +2 -2
  9. package/dist/lib/commands/command-bus.js +1 -6
  10. package/dist/lib/commands/command-bus.js.map +2 -2
  11. package/dist/lib/crud/factory.js +8 -24
  12. package/dist/lib/crud/factory.js.map +2 -2
  13. package/dist/lib/data/engine.js +2 -8
  14. package/dist/lib/data/engine.js.map +2 -2
  15. package/dist/lib/location/countries.js +0 -12
  16. package/dist/lib/location/countries.js.map +2 -2
  17. package/dist/lib/openapi/crud.js +1 -4
  18. package/dist/lib/openapi/crud.js.map +2 -2
  19. package/dist/lib/query/engine.js +34 -270
  20. package/dist/lib/query/engine.js.map +3 -3
  21. package/dist/lib/query/types.js.map +1 -1
  22. package/dist/lib/search/config.js +0 -1
  23. package/dist/lib/search/config.js.map +2 -2
  24. package/dist/lib/version.js +1 -1
  25. package/dist/lib/version.js.map +1 -1
  26. package/dist/modules/events/factory.js +15 -69
  27. package/dist/modules/events/factory.js.map +2 -2
  28. package/dist/modules/registry.js +0 -15
  29. package/dist/modules/registry.js.map +2 -2
  30. package/dist/modules/widgets/component-registry.js.map +2 -2
  31. package/package.json +3 -10
  32. package/src/lib/auth/__tests__/jwt.test.ts +0 -13
  33. package/src/lib/auth/__tests__/organizationAccess.test.ts +1 -36
  34. package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +0 -324
  35. package/src/lib/auth/__tests__/server.test.ts +0 -104
  36. package/src/lib/auth/jwt.ts +0 -17
  37. package/src/lib/auth/organizationAccess.ts +3 -11
  38. package/src/lib/auth/server.ts +8 -78
  39. package/src/lib/commands/__tests__/command-bus.test.ts +0 -31
  40. package/src/lib/commands/command-bus.ts +1 -8
  41. package/src/lib/crud/__tests__/crud-factory.test.ts +0 -165
  42. package/src/lib/crud/factory.ts +7 -33
  43. package/src/lib/data/__tests__/engine.event-validation.test.ts +1 -9
  44. package/src/lib/data/engine.ts +1 -7
  45. package/src/lib/location/__tests__/countries.test.ts +0 -15
  46. package/src/lib/location/countries.ts +0 -17
  47. package/src/lib/openapi/crud.ts +0 -3
  48. package/src/lib/query/__tests__/engine.count-distinct.test.ts +15 -162
  49. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +1 -11
  50. package/src/lib/query/__tests__/engine.test.ts +7 -445
  51. package/src/lib/query/engine.ts +54 -434
  52. package/src/lib/query/types.ts +0 -15
  53. package/src/lib/search/config.ts +0 -10
  54. package/src/modules/events/factory.ts +19 -111
  55. package/src/modules/events/types.ts +0 -17
  56. package/src/modules/registry.ts +0 -40
  57. package/src/modules/widgets/component-registry.ts +0 -14
  58. package/dist/lib/auth/mfaPendingAccess.js +0 -42
  59. package/dist/lib/auth/mfaPendingAccess.js.map +0 -7
  60. package/dist/lib/auth/principal-service.js +0 -1
  61. package/dist/lib/auth/principal-service.js.map +0 -7
  62. package/dist/lib/html/htmlToPlainText.js +0 -16
  63. package/dist/lib/html/htmlToPlainText.js.map +0 -7
  64. package/dist/lib/query/count-cap.js +0 -11
  65. package/dist/lib/query/count-cap.js.map +0 -7
  66. package/dist/lib/queue/dispatchOrigin.js +0 -20
  67. package/dist/lib/queue/dispatchOrigin.js.map +0 -7
  68. package/dist/lib/search/entityAccess.js +0 -44
  69. package/dist/lib/search/entityAccess.js.map +0 -7
  70. package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +0 -69
  71. package/src/lib/auth/__tests__/principalServiceExport.test.ts +0 -67
  72. package/src/lib/auth/mfaPendingAccess.ts +0 -70
  73. package/src/lib/auth/principal-service.ts +0 -110
  74. package/src/lib/html/__tests__/htmlToPlainText.test.ts +0 -59
  75. package/src/lib/html/htmlToPlainText.ts +0 -17
  76. package/src/lib/query/__tests__/count-cap-plan.test.ts +0 -240
  77. package/src/lib/query/__tests__/count-cap.test.ts +0 -41
  78. package/src/lib/query/count-cap.ts +0 -19
  79. package/src/lib/queue/dispatchOrigin.ts +0 -35
  80. package/src/lib/search/entityAccess.ts +0 -132
  81. package/src/modules/events/__tests__/factory.test.ts +0 -88
@@ -94,244 +94,6 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
94
94
  expect(emFlush).toHaveBeenCalledTimes(1)
95
95
  })
96
96
 
97
- it('retains the creator identity for a regular tenant-scoped key', async () => {
98
- const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
99
- findApiKeyBySecret.mockResolvedValue({
100
- id: 'key-tenant-scoped',
101
- name: 'tenant scoped',
102
- tenantId: 'tenant-1',
103
- organizationId: null,
104
- rolesJson: [],
105
- sessionToken: null,
106
- sessionUserId: null,
107
- sessionSecretEncrypted: null,
108
- opencodeSessionId: null,
109
- createdBy: 'creator-1',
110
- expiresAt: null,
111
- lastUsedAt: null,
112
- })
113
- emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
114
- if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
115
- if (where.id === 'creator-1') {
116
- return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'creator-org' }
117
- }
118
- return null
119
- })
120
-
121
- const auth = await getAuthFromRequest(buildRequest('tenant-scoped-secret'))
122
-
123
- expect(auth).toMatchObject({
124
- sub: 'api_key:key-tenant-scoped',
125
- tenantId: 'tenant-1',
126
- orgId: null,
127
- isApiKey: true,
128
- keyId: 'key-tenant-scoped',
129
- userId: 'creator-1',
130
- })
131
- // The creator remains the key's legacy identity, but its concrete
132
- // organization does not narrow a tenant-scoped key.
133
- expect(emFindOne).toHaveBeenCalledWith(
134
- expect.anything(),
135
- { id: 'creator-1', deletedAt: null },
136
- )
137
- })
138
-
139
- it('rejects a tenant-scoped regular key once its creator is soft-deleted', async () => {
140
- const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
141
- findApiKeyBySecret.mockResolvedValue({
142
- id: 'key-deleted-creator',
143
- name: 'deleted creator',
144
- tenantId: 'tenant-1',
145
- organizationId: null,
146
- rolesJson: [],
147
- sessionToken: null,
148
- sessionUserId: null,
149
- sessionSecretEncrypted: null,
150
- opencodeSessionId: null,
151
- createdBy: 'creator-1',
152
- expiresAt: null,
153
- lastUsedAt: null,
154
- })
155
- emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
156
- if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
157
- return null
158
- })
159
-
160
- await expect(
161
- getAuthFromRequest(buildRequest('deleted-creator-secret')),
162
- ).resolves.toBeNull()
163
- })
164
-
165
- it('rejects a tenant-scoped regular key whose creator moved to another tenant', async () => {
166
- const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
167
- findApiKeyBySecret.mockResolvedValue({
168
- id: 'key-foreign-creator',
169
- name: 'foreign creator',
170
- tenantId: 'tenant-1',
171
- organizationId: null,
172
- rolesJson: [],
173
- sessionToken: null,
174
- sessionUserId: null,
175
- sessionSecretEncrypted: null,
176
- opencodeSessionId: null,
177
- createdBy: 'creator-1',
178
- expiresAt: null,
179
- lastUsedAt: null,
180
- })
181
- emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
182
- if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
183
- if (where.id === 'creator-1') {
184
- return { id: 'creator-1', tenantId: 'tenant-2', organizationId: null }
185
- }
186
- return null
187
- })
188
-
189
- await expect(
190
- getAuthFromRequest(buildRequest('foreign-creator-secret')),
191
- ).resolves.toBeNull()
192
- })
193
-
194
- it('accepts a tenant-scoped regular key that records no creator', async () => {
195
- const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
196
- findApiKeyBySecret.mockResolvedValue({
197
- id: 'key-no-creator',
198
- name: 'no creator',
199
- tenantId: 'tenant-1',
200
- organizationId: null,
201
- rolesJson: [],
202
- sessionToken: null,
203
- sessionUserId: null,
204
- sessionSecretEncrypted: null,
205
- opencodeSessionId: null,
206
- createdBy: null,
207
- expiresAt: null,
208
- lastUsedAt: null,
209
- })
210
- emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
211
- if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
212
- return null
213
- })
214
-
215
- await expect(
216
- getAuthFromRequest(buildRequest('no-creator-secret')),
217
- ).resolves.toMatchObject({ keyId: 'key-no-creator', tenantId: 'tenant-1' })
218
- })
219
-
220
- it('retains the creator identity for an organization-scoped regular key', async () => {
221
- const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
222
- findApiKeyBySecret.mockResolvedValue({
223
- id: 'key-organization-scoped',
224
- name: 'organization scoped',
225
- tenantId: 'tenant-1',
226
- organizationId: 'org-1',
227
- rolesJson: [],
228
- sessionToken: null,
229
- sessionUserId: null,
230
- sessionSecretEncrypted: null,
231
- opencodeSessionId: null,
232
- createdBy: 'creator-1',
233
- expiresAt: null,
234
- lastUsedAt: null,
235
- })
236
- emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
237
- if (where.id === 'creator-1') {
238
- return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'org-1' }
239
- }
240
- return null
241
- })
242
-
243
- await expect(
244
- getAuthFromRequest(buildRequest('organization-scoped-secret')),
245
- ).resolves.toMatchObject({
246
- sub: 'api_key:key-organization-scoped',
247
- tenantId: 'tenant-1',
248
- orgId: 'org-1',
249
- userId: 'creator-1',
250
- })
251
- })
252
-
253
- it('rejects an organization-scoped regular key when its creator scope no longer matches', async () => {
254
- const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
255
- findApiKeyBySecret.mockResolvedValue({
256
- id: 'key-organization-mismatch',
257
- name: 'organization mismatch',
258
- tenantId: 'tenant-1',
259
- organizationId: 'org-1',
260
- rolesJson: [],
261
- sessionToken: null,
262
- sessionUserId: null,
263
- sessionSecretEncrypted: null,
264
- opencodeSessionId: null,
265
- createdBy: 'creator-1',
266
- expiresAt: null,
267
- lastUsedAt: null,
268
- })
269
- emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
270
- if (where.id === 'creator-1') {
271
- return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'org-2' }
272
- }
273
- return null
274
- })
275
-
276
- await expect(
277
- getAuthFromRequest(buildRequest('organization-scope-mismatch-secret')),
278
- ).resolves.toBeNull()
279
- })
280
-
281
- it('keeps session keys strictly bound to their persisted user and scope', async () => {
282
- const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
283
- findApiKeyBySecret.mockResolvedValue({
284
- id: 'key-session-scoped',
285
- name: 'session scoped',
286
- tenantId: 'tenant-1',
287
- organizationId: null,
288
- rolesJson: [],
289
- sessionToken: 'sess_123',
290
- sessionUserId: 'session-user-1',
291
- sessionSecretEncrypted: null,
292
- opencodeSessionId: null,
293
- createdBy: 'session-user-1',
294
- expiresAt: null,
295
- lastUsedAt: null,
296
- })
297
- emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
298
- if (where.id === 'session-user-1') {
299
- return { id: 'session-user-1', tenantId: 'tenant-1', organizationId: 'user-org' }
300
- }
301
- return null
302
- })
303
-
304
- await expect(
305
- getAuthFromRequest(buildRequest('session-scope-mismatch-secret')),
306
- ).resolves.toBeNull()
307
- })
308
-
309
- it('fails closed when a row has session markers but no bound session user', async () => {
310
- const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
311
- findApiKeyBySecret.mockResolvedValue({
312
- id: 'key-malformed-session',
313
- name: 'malformed session',
314
- tenantId: 'tenant-1',
315
- organizationId: null,
316
- rolesJson: [],
317
- sessionToken: 'sess_missing_user',
318
- sessionUserId: null,
319
- sessionSecretEncrypted: null,
320
- opencodeSessionId: null,
321
- createdBy: 'creator-1',
322
- expiresAt: null,
323
- lastUsedAt: null,
324
- })
325
-
326
- await expect(
327
- getAuthFromRequest(buildRequest('malformed-session-secret')),
328
- ).resolves.toBeNull()
329
- expect(emFindOne).not.toHaveBeenCalledWith(
330
- expect.anything(),
331
- expect.objectContaining({ id: 'creator-1' }),
332
- )
333
- })
334
-
335
97
  it('caches negative lookups so invalid keys skip the bcrypt+DB path', async () => {
336
98
  const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
337
99
  findApiKeyBySecret.mockResolvedValue(null)
@@ -366,90 +128,4 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
366
128
  await getAuthFromRequest(buildRequest('secret-invalidate'))
367
129
  expect(findApiKeyBySecret).toHaveBeenCalledTimes(2)
368
130
  })
369
-
370
- describe('super-admin bit stays bounded by the effective key scope', () => {
371
- async function resolveWithSuperAdminRole(input: {
372
- secret: string
373
- keyId: string
374
- keyOrganizationId: string | null
375
- aclOrganizations: string[] | null
376
- }) {
377
- const { RoleAcl } = await import('@open-mercato/core/modules/auth/data/entities')
378
- const { Organization, Tenant } = await import('@open-mercato/core/modules/directory/data/entities')
379
- const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
380
- // A creator-less key is validated against a live tenant (and organization when bound).
381
- emFindOne.mockImplementation(async (entity: unknown) => {
382
- if (entity === Tenant) return { id: 'tenant-1' }
383
- if (entity === Organization) return { id: input.keyOrganizationId, tenant: { id: 'tenant-1' } }
384
- return null
385
- })
386
- findApiKeyBySecret.mockResolvedValue({
387
- id: input.keyId,
388
- name: 'super admin key',
389
- tenantId: 'tenant-1',
390
- organizationId: input.keyOrganizationId,
391
- rolesJson: ['role-super'],
392
- sessionToken: null,
393
- sessionUserId: null,
394
- sessionSecretEncrypted: null,
395
- opencodeSessionId: null,
396
- createdBy: null,
397
- expiresAt: null,
398
- lastUsedAt: null,
399
- })
400
- emFind.mockImplementation(async (entity: unknown) => {
401
- if (entity === RoleAcl) {
402
- return [{ isSuperAdmin: true, organizationsJson: input.aclOrganizations }]
403
- }
404
- return []
405
- })
406
- return getAuthFromRequest(buildRequest(input.secret))
407
- }
408
-
409
- it('withholds it from an organization-bound key even when a role grants it', async () => {
410
- // Generic guards trust this bit before live RBAC, so an organization-restricted key must
411
- // never take super-admin shortcuts outside its own scope.
412
- const auth = await resolveWithSuperAdminRole({
413
- secret: 'super-bound',
414
- keyId: 'key-super-bound',
415
- keyOrganizationId: 'org-1',
416
- aclOrganizations: null,
417
- })
418
-
419
- expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: false })
420
- })
421
-
422
- it('withholds it when the granting role ACL is itself organization-restricted', async () => {
423
- const auth = await resolveWithSuperAdminRole({
424
- secret: 'super-restricted',
425
- keyId: 'key-super-restricted',
426
- keyOrganizationId: null,
427
- aclOrganizations: ['org-1'],
428
- })
429
-
430
- expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: false })
431
- })
432
-
433
- it('grants it for an unbound key with an unrestricted role grant', async () => {
434
- const auth = await resolveWithSuperAdminRole({
435
- secret: 'super-global',
436
- keyId: 'key-super-global',
437
- keyOrganizationId: null,
438
- aclOrganizations: null,
439
- })
440
-
441
- expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: true })
442
- })
443
-
444
- it('grants it for an unbound key whose role ACL uses the __all__ sentinel', async () => {
445
- const auth = await resolveWithSuperAdminRole({
446
- secret: 'super-all-sentinel',
447
- keyId: 'key-super-all-sentinel',
448
- keyOrganizationId: null,
449
- aclOrganizations: ['__all__'],
450
- })
451
-
452
- expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: true })
453
- })
454
- })
455
131
  })
@@ -10,7 +10,6 @@ jest.mock('next/headers', () => ({
10
10
  }))
11
11
 
12
12
  jest.mock('@open-mercato/shared/lib/auth/jwt', () => ({
13
- ...jest.requireActual('@open-mercato/shared/lib/auth/jwt'),
14
13
  verifyJwt: (...args: unknown[]) => verifyJwt(...args),
15
14
  }))
16
15
 
@@ -163,109 +162,6 @@ describe('auth server integrity checks', () => {
163
162
  await expect(getAuthFromRequest(request)).resolves.toBeNull()
164
163
  })
165
164
 
166
- describe('mfa-pending staff tokens', () => {
167
- const pendingAuth = {
168
- sub: '11111111-1111-4111-8111-111111111111',
169
- sid: '44444444-4444-4444-8444-444444444444',
170
- tenantId: '22222222-2222-4222-8222-222222222222',
171
- orgId: '33333333-3333-4333-8333-333333333333',
172
- roles: ['admin'],
173
- mfa_pending: true,
174
- mfa_verified: false,
175
- }
176
-
177
- function mfaPendingRequest(path: string, method: string): Request {
178
- return new Request(`https://example.test${path}`, {
179
- method,
180
- headers: { authorization: `Bearer pending-token` },
181
- })
182
- }
183
-
184
- beforeEach(async () => {
185
- // The registry ships empty; completion routes are registered by their owning module
186
- // (enterprise security). Register equivalent fixtures to exercise resolver mechanics.
187
- const { registerMfaPendingAccessRoutes } = await import('../mfaPendingAccess')
188
- registerMfaPendingAccessRoutes([
189
- { path: '/api/security/mfa/prepare', methods: ['POST'] },
190
- { path: '/api/security/mfa/verify', methods: ['POST'] },
191
- { path: '/api/security/mfa/recovery', methods: ['POST'] },
192
- ])
193
- verifyJwt.mockReturnValue(pendingAuth)
194
- resolveCanonicalStaffAuthContext.mockResolvedValue({ ...pendingAuth })
195
- })
196
-
197
- it('rejects a pending token on a general staff API before roles are restored', async () => {
198
- const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
199
-
200
- await expect(resolveAuthFromRequestDetailed(mfaPendingRequest('/api/customers/people', 'GET')))
201
- .resolves.toEqual({ auth: null, status: 'invalid' })
202
- expect(resolveCanonicalStaffAuthContext).not.toHaveBeenCalled()
203
- })
204
-
205
- it('still resolves a pending token on the registered MFA completion routes (POST)', async () => {
206
- const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
207
-
208
- for (const path of ['/api/security/mfa/prepare', '/api/security/mfa/verify', '/api/security/mfa/recovery']) {
209
- resolveCanonicalStaffAuthContext.mockClear()
210
- const resolution = await resolveAuthFromRequestDetailed(mfaPendingRequest(path, 'POST'))
211
- expect(resolution.status).toBe('authenticated')
212
- expect(resolution.auth).toEqual({ ...pendingAuth })
213
- expect(resolveCanonicalStaffAuthContext).toHaveBeenCalledWith(em, pendingAuth)
214
- }
215
- })
216
-
217
- it('rejects a pending token with a non-completion method on a completion route', async () => {
218
- const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
219
-
220
- await expect(resolveAuthFromRequestDetailed(mfaPendingRequest('/api/security/mfa/verify', 'GET')))
221
- .resolves.toEqual({ auth: null, status: 'invalid' })
222
- expect(resolveCanonicalStaffAuthContext).not.toHaveBeenCalled()
223
- })
224
-
225
- it('resolves the verified replacement token like a normal login', async () => {
226
- const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
227
- const verifiedAuth = { ...pendingAuth, mfa_pending: false, mfa_verified: true }
228
- verifyJwt.mockReturnValue(verifiedAuth)
229
- resolveCanonicalStaffAuthContext.mockResolvedValue({ ...verifiedAuth, roles: ['employee'] })
230
-
231
- const request = new Request('https://example.test/api/customers/people', {
232
- headers: { authorization: 'Bearer verified-token' },
233
- })
234
-
235
- await expect(getAuthFromRequest(request)).resolves.toEqual({ ...verifiedAuth, roles: ['employee'] })
236
- expect(resolveCanonicalStaffAuthContext).toHaveBeenCalledWith(em, verifiedAuth)
237
- })
238
-
239
- it('leaves tokens without MFA claims untouched', async () => {
240
- const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
241
- const plainAuth = {
242
- sub: '11111111-1111-4111-8111-111111111111',
243
- tenantId: '22222222-2222-4222-8222-222222222222',
244
- orgId: '33333333-3333-4333-8333-333333333333',
245
- roles: [],
246
- }
247
- verifyJwt.mockReturnValue(plainAuth)
248
- resolveCanonicalStaffAuthContext.mockResolvedValue(plainAuth)
249
-
250
- const request = new Request('https://example.test/api/test', {
251
- headers: { cookie: 'auth_token=jwt-token' },
252
- })
253
-
254
- await expect(getAuthFromRequest(request)).resolves.toEqual(plainAuth)
255
- })
256
-
257
- it('rejects a pending token from cookie-based page resolution unconditionally', async () => {
258
- const { getAuthFromCookies, resolveAuthFromCookiesDetailed } = await import('@open-mercato/shared/lib/auth/server')
259
- cookieStore.get.mockImplementation((name: string) => {
260
- if (name === 'auth_token') return { value: 'pending-jwt-token' }
261
- return undefined
262
- })
263
-
264
- await expect(getAuthFromCookies()).resolves.toBeNull()
265
- await expect(resolveAuthFromCookiesDetailed()).resolves.toEqual({ auth: null, status: 'invalid' })
266
- expect(resolveCanonicalStaffAuthContext).not.toHaveBeenCalled()
267
- })
268
- })
269
165
  it('reports a transient DB failure on the api-key path as "error" (retryable 503)', async () => {
270
166
  const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
271
167
  // Distinct secret so the shared api-key auth cache does not serve a prior miss.
@@ -377,20 +377,3 @@ export function signAudienceJwt(
377
377
  export function verifyAudienceJwt(audience: string, token: string): JwtPayload | null {
378
378
  return verifyJwt(token, { audience })
379
379
  }
380
-
381
- /**
382
- * True when a verified JWT payload represents an MFA challenge in progress: the second factor
383
- * has not been completed yet (`mfa_pending: true`, `mfa_verified` not `true`). Such tokens are
384
- * provisional — general staff APIs must reject them and only the MFA completion routes may
385
- * accept them (see `@open-mercato/shared/lib/auth/mfaPendingAccess`).
386
- *
387
- * The predicate keys on `mfa_pending` alone by design. `mfa_verified` is NOT a durable session
388
- * property: routes that legitimately re-sign a staff token (`PUT /api/auth/profile`,
389
- * `GET|POST /api/auth/session/refresh`) rebuild the claims without it. Tightening this to require
390
- * `mfa_verified === true` would therefore silently downgrade those sessions.
391
- */
392
- export function isMfaPendingJwtPayload(payload: unknown): boolean {
393
- if (!payload || typeof payload !== 'object') return false
394
- const record = payload as Record<string, unknown>
395
- return record.mfa_pending === true && record.mfa_verified !== true
396
- }
@@ -4,15 +4,6 @@ export type OrganizationAccessDecisionInput = {
4
4
  targetOrganizationId: string | null
5
5
  }
6
6
 
7
- export type UnrestrictedOrganizationScopeInput = {
8
- isSuperAdmin: boolean
9
- allowedOrganizationIds: readonly string[] | null | undefined
10
- }
11
-
12
- export function isUnrestrictedOrganizationScope(input: UnrestrictedOrganizationScopeInput): boolean {
13
- return input.isSuperAdmin || input.allowedOrganizationIds === null
14
- }
15
-
16
7
  /**
17
8
  * Fail-closed organization-access predicate. The single source of truth for
18
9
  * "may this principal act on `targetOrganizationId`".
@@ -24,7 +15,8 @@ export function isUnrestrictedOrganizationScope(input: UnrestrictedOrganizationS
24
15
  * - restricted + target org -> allow iff the target is a member of the allowed set
25
16
  */
26
17
  export function isOrganizationAccessAllowed(input: OrganizationAccessDecisionInput): boolean {
27
- if (isUnrestrictedOrganizationScope(input)) return true
18
+ if (input.isSuperAdmin) return true
19
+ if (input.allowedOrganizationIds === null) return true
28
20
  if (!input.targetOrganizationId) return false
29
- return (input.allowedOrganizationIds ?? []).includes(input.targetOrganizationId)
21
+ return input.allowedOrganizationIds.includes(input.targetOrganizationId)
30
22
  }
@@ -1,18 +1,9 @@
1
1
  import { cookies } from 'next/headers.js'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
- import { isMfaPendingJwtPayload, verifyJwt } from './jwt'
4
- import { isMfaPendingAccessAllowed } from './mfaPendingAccess'
3
+ import { verifyJwt } from './jwt'
5
4
  import { getSharedApiKeyAuthCache } from './apiKeyAuthCache'
6
5
  import { isTransientDbError } from '@open-mercato/shared/lib/db/pg-errors'
7
6
 
8
- function readRequestPathname(req: Request): string | null {
9
- try {
10
- return new URL(req.url).pathname
11
- } catch {
12
- return null
13
- }
14
- }
15
-
16
7
  const TENANT_COOKIE_NAME = 'om_selected_tenant'
17
8
  const ORGANIZATION_COOKIE_NAME = 'om_selected_org'
18
9
  const ALL_ORGANIZATIONS_COOKIE_VALUE = '__all__'
@@ -68,16 +59,6 @@ export type TrustedAuthContextEnvelope = {
68
59
  status?: AuthResolutionStatus
69
60
  }
70
61
 
71
- /**
72
- * Attach an already-resolved auth context to a synthetic request.
73
- *
74
- * INVARIANT callers MUST uphold: `envelope.auth` has to be the product of a gated resolution —
75
- * `getAuthFromRequest` / `getAuthFromCookies` (or an equivalently validated principal such as an
76
- * API-key record). The envelope short-circuits `resolveAuthFromRequestDetailed` ahead of both the
77
- * MFA-pending gate and `resolveCanonicalStaffAuthContext`, so an envelope built straight from an
78
- * unvalidated JWT would reinstate the MFA bypass this module closes and skip session-revocation
79
- * checks as well. Never populate it from raw request credentials.
80
- */
81
62
  export function attachTrustedAuthContext(
82
63
  request: Request,
83
64
  envelope: TrustedAuthContextEnvelope
@@ -214,33 +195,13 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
214
195
  : []
215
196
  const roleNames = roles.map((role) => role.name).filter((name): name is string => typeof name === 'string' && name.length > 0)
216
197
 
217
- // A role-level super-admin grant is authorization-grade only when it is genuinely
218
- // unrestricted: the grant itself must not be organization-scoped, it must belong to the
219
- // key's tenant, and the key must not be bound to a single organization. RbacService applies
220
- // the same intersection when projecting the scoped ACL, but generic guards
221
- // (tenantAccess.resolveIsSuperAdmin, the scoped-API helpers) trust this raw bit *before*
222
- // live RBAC runs — so an organization-restricted key must never carry it.
223
198
  let keyIsSuperAdmin = false
224
- const keyOrganizationId = typeof record.organizationId === 'string' && record.organizationId.trim().length > 0
225
- ? record.organizationId.trim()
226
- : null
227
- if (roleIds.length && !keyOrganizationId) {
228
- const superAcls = await em.find(
199
+ if (roleIds.length) {
200
+ const superAcl = await em.findOne(
229
201
  RoleAcl,
230
- {
231
- role: { $in: roleIds } as any,
232
- tenantId: record.tenantId ?? null,
233
- isSuperAdmin: true,
234
- deletedAt: null,
235
- } as any,
202
+ { role: { $in: roleIds } as any, isSuperAdmin: true, deletedAt: null } as any,
236
203
  )
237
- keyIsSuperAdmin = superAcls.some((acl) => {
238
- const organizations = Array.isArray((acl as { organizationsJson?: string[] | null }).organizationsJson)
239
- ? (acl as { organizationsJson?: string[] | null }).organizationsJson as string[]
240
- : null
241
- // An empty list means "inherit the key's own binding"; with no binding that is tenant-wide.
242
- return !organizations || organizations.length === 0 || organizations.includes('__all__')
243
- })
204
+ keyIsSuperAdmin = !!(superAcl && (superAcl as { isSuperAdmin?: boolean }).isSuperAdmin)
244
205
  }
245
206
 
246
207
  if (cache.shouldWriteLastUsed(record.id)) {
@@ -252,25 +213,8 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
252
213
  }
253
214
  }
254
215
 
255
- // Ephemeral session keys are always user-bound. Regular keys retain their
256
- // legacy creator identity, while tenant-scoped regular keys ignore only the
257
- // creator's concrete organization when validating the wider key scope.
258
- // Keep every session marker fail-closed so a malformed session key cannot
259
- // fall back to the regular key path and escape its user/scope binding.
260
- const isSessionBoundKey = Boolean(
261
- record.sessionToken
262
- || record.sessionUserId
263
- || record.sessionSecretEncrypted
264
- || record.opencodeSessionId
265
- )
266
- const actualUserId = isSessionBoundKey
267
- ? record.sessionUserId ?? null
268
- : record.createdBy ?? null
269
-
270
- if (isSessionBoundKey && !actualUserId) {
271
- cache.setMiss(secret)
272
- return null
273
- }
216
+ // For session keys, use sessionUserId; for regular keys, use createdBy
217
+ const actualUserId = record.sessionUserId ?? record.createdBy ?? null
274
218
 
275
219
  if (actualUserId) {
276
220
  const user = await em.findOne(User, { id: actualUserId, deletedAt: null })
@@ -282,8 +226,7 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
282
226
  cache.setMiss(secret)
283
227
  return null
284
228
  }
285
- const requiresExactOrganization = isSessionBoundKey || Boolean(record.organizationId)
286
- if (requiresExactOrganization && (user.organizationId ?? null) !== (record.organizationId ?? null)) {
229
+ if ((user.organizationId ?? null) !== (record.organizationId ?? null)) {
287
230
  cache.setMiss(secret)
288
231
  return null
289
232
  }
@@ -372,9 +315,6 @@ export async function resolveAuthFromCookiesDetailed(): Promise<AuthResolution>
372
315
  const payload = verifyJwt(token) as AuthContext
373
316
  if (!payload) return { auth: null, status: 'invalid' }
374
317
  if (payload.type === 'customer') return { auth: null, status: 'invalid' }
375
- // MFA-pending tokens are provisional: pages never complete the second factor, so they
376
- // must not resolve to an authenticated context anywhere in the RSC/page flow.
377
- if (isMfaPendingJwtPayload(payload)) return { auth: null, status: 'invalid' }
378
318
  const canonicalAuth = await resolveCanonicalInteractiveAuthContext(payload)
379
319
  if (!canonicalAuth) return { auth: null, status: 'invalid' }
380
320
  const tenantCookie = cookieStore.get(TENANT_COOKIE_NAME)?.value
@@ -394,8 +334,6 @@ export async function getAuthFromCookies(): Promise<AuthContext> {
394
334
  }
395
335
 
396
336
  export async function resolveAuthFromRequestDetailed(req: Request): Promise<AuthResolution> {
397
- // Deliberately ahead of the MFA-pending gate below: the envelope carries a context that was
398
- // already gated by its producer (see `attachTrustedAuthContext`), not raw request credentials.
399
337
  const trusted = readTrustedAuthContext(req)
400
338
  if (trusted) {
401
339
  return {
@@ -419,14 +357,6 @@ export async function resolveAuthFromRequestDetailed(req: Request): Promise<Auth
419
357
  try {
420
358
  const payload = verifyJwt(token) as AuthContext
421
359
  if (payload && payload.type === 'customer') return { auth: null, status: 'invalid' }
422
- if (payload && isMfaPendingJwtPayload(payload)) {
423
- // MFA-pending tokens authenticate only the registered challenge-completion routes;
424
- // everywhere else they resolve to `invalid` so the dispatcher answers 401 (clearing
425
- // staff auth cookies) instead of restoring the account's roles/features.
426
- if (!isMfaPendingAccessAllowed(req.method, readRequestPathname(req))) {
427
- return { auth: null, status: 'invalid' }
428
- }
429
- }
430
360
  if (payload) {
431
361
  const canonicalAuth = await resolveCanonicalInteractiveAuthContext(payload)
432
362
  if (canonicalAuth) {