@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -4
- package/dist/lib/auth/jwt.js +0 -6
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/organizationAccess.js +4 -7
- package/dist/lib/auth/organizationAccess.js.map +2 -2
- package/dist/lib/auth/server.js +7 -38
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/commands/command-bus.js +1 -6
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/crud/factory.js +8 -24
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +2 -8
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/location/countries.js +0 -12
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/openapi/crud.js +1 -4
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/engine.js +34 -270
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/search/config.js +0 -1
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +15 -69
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +0 -15
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/component-registry.js.map +2 -2
- package/package.json +3 -10
- package/src/lib/auth/__tests__/jwt.test.ts +0 -13
- package/src/lib/auth/__tests__/organizationAccess.test.ts +1 -36
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +0 -324
- package/src/lib/auth/__tests__/server.test.ts +0 -104
- package/src/lib/auth/jwt.ts +0 -17
- package/src/lib/auth/organizationAccess.ts +3 -11
- package/src/lib/auth/server.ts +8 -78
- package/src/lib/commands/__tests__/command-bus.test.ts +0 -31
- package/src/lib/commands/command-bus.ts +1 -8
- package/src/lib/crud/__tests__/crud-factory.test.ts +0 -165
- package/src/lib/crud/factory.ts +7 -33
- package/src/lib/data/__tests__/engine.event-validation.test.ts +1 -9
- package/src/lib/data/engine.ts +1 -7
- package/src/lib/location/__tests__/countries.test.ts +0 -15
- package/src/lib/location/countries.ts +0 -17
- package/src/lib/openapi/crud.ts +0 -3
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +15 -162
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +1 -11
- package/src/lib/query/__tests__/engine.test.ts +7 -445
- package/src/lib/query/engine.ts +54 -434
- package/src/lib/query/types.ts +0 -15
- package/src/lib/search/config.ts +0 -10
- package/src/modules/events/factory.ts +19 -111
- package/src/modules/events/types.ts +0 -17
- package/src/modules/registry.ts +0 -40
- package/src/modules/widgets/component-registry.ts +0 -14
- package/dist/lib/auth/mfaPendingAccess.js +0 -42
- package/dist/lib/auth/mfaPendingAccess.js.map +0 -7
- package/dist/lib/auth/principal-service.js +0 -1
- package/dist/lib/auth/principal-service.js.map +0 -7
- package/dist/lib/html/htmlToPlainText.js +0 -16
- package/dist/lib/html/htmlToPlainText.js.map +0 -7
- package/dist/lib/query/count-cap.js +0 -11
- package/dist/lib/query/count-cap.js.map +0 -7
- package/dist/lib/queue/dispatchOrigin.js +0 -20
- package/dist/lib/queue/dispatchOrigin.js.map +0 -7
- package/dist/lib/search/entityAccess.js +0 -44
- package/dist/lib/search/entityAccess.js.map +0 -7
- package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +0 -69
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +0 -67
- package/src/lib/auth/mfaPendingAccess.ts +0 -70
- package/src/lib/auth/principal-service.ts +0 -110
- package/src/lib/html/__tests__/htmlToPlainText.test.ts +0 -59
- package/src/lib/html/htmlToPlainText.ts +0 -17
- package/src/lib/query/__tests__/count-cap-plan.test.ts +0 -240
- package/src/lib/query/__tests__/count-cap.test.ts +0 -41
- package/src/lib/query/count-cap.ts +0 -19
- package/src/lib/queue/dispatchOrigin.ts +0 -35
- package/src/lib/search/entityAccess.ts +0 -132
- 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.
|
package/src/lib/auth/jwt.ts
CHANGED
|
@@ -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 (
|
|
18
|
+
if (input.isSuperAdmin) return true
|
|
19
|
+
if (input.allowedOrganizationIds === null) return true
|
|
28
20
|
if (!input.targetOrganizationId) return false
|
|
29
|
-
return
|
|
21
|
+
return input.allowedOrganizationIds.includes(input.targetOrganizationId)
|
|
30
22
|
}
|
package/src/lib/auth/server.ts
CHANGED
|
@@ -1,18 +1,9 @@
|
|
|
1
1
|
import { cookies } from 'next/headers.js'
|
|
2
2
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
-
import {
|
|
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
|
-
|
|
225
|
-
|
|
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 =
|
|
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
|
-
//
|
|
256
|
-
|
|
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
|
-
|
|
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) {
|