@shipfox/api-auth 9.3.0 → 10.0.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 (48) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +35 -0
  3. package/README.md +10 -3
  4. package/dist/core/administration.d.ts +18 -2
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +34 -4
  7. package/dist/core/administration.js.map +1 -1
  8. package/dist/core/entities/administrator-read-model.d.ts +21 -0
  9. package/dist/core/entities/administrator-read-model.d.ts.map +1 -0
  10. package/dist/core/entities/administrator-read-model.js +3 -0
  11. package/dist/core/entities/administrator-read-model.js.map +1 -0
  12. package/dist/db/admin-grants.d.ts +16 -1
  13. package/dist/db/admin-grants.d.ts.map +1 -1
  14. package/dist/db/admin-grants.js +32 -4
  15. package/dist/db/admin-grants.js.map +1 -1
  16. package/dist/db/admin-users.d.ts +21 -0
  17. package/dist/db/admin-users.d.ts.map +1 -0
  18. package/dist/db/admin-users.js +32 -0
  19. package/dist/db/admin-users.js.map +1 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +4 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/metrics/instance.d.ts +1 -1
  25. package/dist/metrics/instance.d.ts.map +1 -1
  26. package/dist/metrics/instance.js.map +1 -1
  27. package/dist/presentation/routes/administration.d.ts +1 -0
  28. package/dist/presentation/routes/administration.d.ts.map +1 -1
  29. package/dist/presentation/routes/administration.js +91 -10
  30. package/dist/presentation/routes/administration.js.map +1 -1
  31. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  32. package/dist/presentation/routes/rate-limit.js +6 -0
  33. package/dist/presentation/routes/rate-limit.js.map +1 -1
  34. package/dist/tsconfig.test.tsbuildinfo +1 -1
  35. package/package.json +7 -7
  36. package/src/core/administration.ts +45 -4
  37. package/src/core/entities/administrator-read-model.ts +23 -0
  38. package/src/db/admin-grants.test.ts +1 -7
  39. package/src/db/admin-grants.ts +52 -4
  40. package/src/db/admin-users.ts +58 -0
  41. package/src/index.test.ts +1 -1
  42. package/src/index.ts +5 -2
  43. package/src/metrics/instance.ts +1 -1
  44. package/src/presentation/routes/administration.test.ts +304 -20
  45. package/src/presentation/routes/administration.ts +91 -10
  46. package/src/presentation/routes/rate-limit.ts +3 -0
  47. package/test/routes.ts +5 -2
  48. package/tsconfig.build.tsbuildinfo +1 -1
@@ -40,7 +40,7 @@ describe('Auth administration routes', () => {
40
40
  test('requires an authenticated session for bootstrap', async () => {
41
41
  const response = await app.inject({
42
42
  method: 'POST',
43
- url: '/admin/v1/auth/admin-grants/bootstrap',
43
+ url: '/admin/auth/admin-grants/bootstrap',
44
44
  headers: {'idempotency-key': 'anonymous-bootstrap'},
45
45
  payload: {bootstrap_token: BOOTSTRAP_TOKEN},
46
46
  });
@@ -48,12 +48,21 @@ describe('Auth administration routes', () => {
48
48
  expect(response.statusCode).toBe(401);
49
49
  });
50
50
 
51
+ test('does not register the removed versioned administration namespace', async () => {
52
+ const response = await app.inject({
53
+ method: 'GET',
54
+ url: '/admin/v1/auth/users?user_id=00000000-0000-4000-8000-000000000000',
55
+ });
56
+
57
+ expect(response.statusCode).toBe(404);
58
+ });
59
+
51
60
  test('rejects an invalid bootstrap token without writing a grant or event', async () => {
52
61
  const account = await createVerifiedSession('admin-bootstrap-invalid');
53
62
 
54
63
  const response = await app.inject({
55
64
  method: 'POST',
56
- url: '/admin/v1/auth/admin-grants/bootstrap',
65
+ url: '/admin/auth/admin-grants/bootstrap',
57
66
  headers: authHeaders(account.token, 'invalid-bootstrap'),
58
67
  payload: {bootstrap_token: 'wrong-token'},
59
68
  });
@@ -69,7 +78,7 @@ describe('Auth administration routes', () => {
69
78
  for (let attempt = 0; attempt < 5; attempt += 1) {
70
79
  const response = await app.inject({
71
80
  method: 'POST',
72
- url: '/admin/v1/auth/admin-grants/bootstrap',
81
+ url: '/admin/auth/admin-grants/bootstrap',
73
82
  headers: authHeaders(account.token, `bootstrap-rate-limit-${attempt}`),
74
83
  payload: {bootstrap_token: 'wrong-token'},
75
84
  });
@@ -78,7 +87,7 @@ describe('Auth administration routes', () => {
78
87
 
79
88
  const blocked = await app.inject({
80
89
  method: 'POST',
81
- url: '/admin/v1/auth/admin-grants/bootstrap',
90
+ url: '/admin/auth/admin-grants/bootstrap',
82
91
  headers: authHeaders(account.token, 'bootstrap-rate-limit-blocked'),
83
92
  payload: {bootstrap_token: 'wrong-token'},
84
93
  });
@@ -92,7 +101,7 @@ describe('Auth administration routes', () => {
92
101
 
93
102
  const response = await app.inject({
94
103
  method: 'POST',
95
- url: '/admin/v1/auth/admin-grants/bootstrap',
104
+ url: '/admin/auth/admin-grants/bootstrap',
96
105
  headers: authHeaders(first.token, 'bootstrap-first'),
97
106
  payload: {bootstrap_token: BOOTSTRAP_TOKEN},
98
107
  });
@@ -102,7 +111,7 @@ describe('Auth administration routes', () => {
102
111
 
103
112
  const repeated = await app.inject({
104
113
  method: 'POST',
105
- url: '/admin/v1/auth/admin-grants/bootstrap',
114
+ url: '/admin/auth/admin-grants/bootstrap',
106
115
  headers: authHeaders(first.token, 'bootstrap-first'),
107
116
  payload: {bootstrap_token: BOOTSTRAP_TOKEN},
108
117
  });
@@ -111,7 +120,7 @@ describe('Auth administration routes', () => {
111
120
 
112
121
  const secondAttempt = await app.inject({
113
122
  method: 'POST',
114
- url: '/admin/v1/auth/admin-grants/bootstrap',
123
+ url: '/admin/auth/admin-grants/bootstrap',
115
124
  headers: authHeaders(second.token, 'bootstrap-second'),
116
125
  payload: {bootstrap_token: BOOTSTRAP_TOKEN},
117
126
  });
@@ -137,7 +146,7 @@ describe('Auth administration routes', () => {
137
146
 
138
147
  const bootstrap = await app.inject({
139
148
  method: 'POST',
140
- url: '/admin/v1/auth/admin-grants/bootstrap',
149
+ url: '/admin/auth/admin-grants/bootstrap',
141
150
  headers: authHeaders(owner.token, 'grant-bootstrap'),
142
151
  payload: {bootstrap_token: BOOTSTRAP_TOKEN},
143
152
  });
@@ -145,7 +154,7 @@ describe('Auth administration routes', () => {
145
154
 
146
155
  const grant = await app.inject({
147
156
  method: 'POST',
148
- url: '/admin/v1/auth/admin-grants',
157
+ url: '/admin/auth/admin-grants',
149
158
  headers: authHeaders(owner.token, 'grant-observer'),
150
159
  payload: {
151
160
  user_id: target.userId,
@@ -157,7 +166,7 @@ describe('Auth administration routes', () => {
157
166
 
158
167
  const repeatedGrant = await app.inject({
159
168
  method: 'POST',
160
- url: '/admin/v1/auth/admin-grants',
169
+ url: '/admin/auth/admin-grants',
161
170
  headers: authHeaders(owner.token, 'grant-observer'),
162
171
  payload: {
163
172
  user_id: target.userId,
@@ -170,17 +179,19 @@ describe('Auth administration routes', () => {
170
179
 
171
180
  const grants = await app.inject({
172
181
  method: 'GET',
173
- url: '/admin/v1/auth/admin-grants',
182
+ url: '/admin/auth/admin-grants',
174
183
  headers: {authorization: `Bearer ${owner.token}`},
175
184
  });
176
185
  expect(grants.statusCode).toBe(200);
177
186
  expect(grants.json().grants).toEqual(
178
- expect.arrayContaining([expect.objectContaining({user_id: target.userId})]),
187
+ expect.arrayContaining([
188
+ expect.objectContaining({user: expect.objectContaining({id: target.userId})}),
189
+ ]),
179
190
  );
180
191
 
181
192
  const revoked = await app.inject({
182
193
  method: 'DELETE',
183
- url: `/admin/v1/auth/admin-grants/${grant.json().id}`,
194
+ url: `/admin/auth/admin-grants/${grant.json().id}`,
184
195
  headers: authHeaders(owner.token, 'revoke-observer'),
185
196
  payload: {reason: 'Support investigation complete'},
186
197
  });
@@ -189,7 +200,7 @@ describe('Auth administration routes', () => {
189
200
 
190
201
  const repeatedRevoke = await app.inject({
191
202
  method: 'DELETE',
192
- url: `/admin/v1/auth/admin-grants/${grant.json().id}`,
203
+ url: `/admin/auth/admin-grants/${grant.json().id}`,
193
204
  headers: authHeaders(owner.token, 'revoke-observer'),
194
205
  payload: {reason: 'Support investigation complete'},
195
206
  });
@@ -203,20 +214,293 @@ describe('Auth administration routes', () => {
203
214
  );
204
215
  });
205
216
 
217
+ test('lets an observer find exact IDs and normalized emails without exposing user secrets', async () => {
218
+ const owner = await createVerifiedSession('admin-user-lookup-owner');
219
+ const observer = await createVerifiedSession('admin-user-lookup-observer');
220
+
221
+ await app.inject({
222
+ method: 'POST',
223
+ url: '/admin/auth/admin-grants/bootstrap',
224
+ headers: authHeaders(owner.token, 'user-lookup-bootstrap'),
225
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
226
+ });
227
+ await app.inject({
228
+ method: 'POST',
229
+ url: '/admin/auth/admin-grants',
230
+ headers: authHeaders(owner.token, 'user-lookup-grant'),
231
+ payload: {
232
+ user_id: observer.userId,
233
+ role: 'admin-observer',
234
+ reason: 'Support lookup access',
235
+ },
236
+ });
237
+
238
+ const byId = await app.inject({
239
+ method: 'GET',
240
+ url: `/admin/auth/users?user_id=${observer.userId}`,
241
+ headers: {authorization: `Bearer ${observer.token}`},
242
+ });
243
+
244
+ expect(byId.statusCode).toBe(200);
245
+ expect(byId.json()).toEqual({
246
+ id: observer.userId,
247
+ email: observer.email,
248
+ name: 'admin-user-lookup-observer',
249
+ status: 'active',
250
+ email_verified_at: expect.any(String),
251
+ created_at: expect.any(String),
252
+ admin_role: 'admin-observer',
253
+ });
254
+ expect(byId.json()).not.toHaveProperty('hashed_password');
255
+ expect(byId.json()).not.toHaveProperty('sessions');
256
+ expect(byId.json()).not.toHaveProperty('provider_payload');
257
+
258
+ const byEmail = await app.inject({
259
+ method: 'GET',
260
+ url: `/admin/auth/users?email=${encodeURIComponent(` ${observer.email.toUpperCase()} `)}`,
261
+ headers: {authorization: `Bearer ${observer.token}`},
262
+ });
263
+
264
+ expect(byEmail.statusCode).toBe(200);
265
+ expect(byEmail.json().id).toBe(observer.userId);
266
+ expect(byEmail.json().email).toBe(observer.email);
267
+ });
268
+
269
+ test('bounds and deterministically paginates administrator grant summaries for observers', async () => {
270
+ const owner = await createVerifiedSession('admin-summary-owner');
271
+ const observer = await createVerifiedSession('admin-summary-observer');
272
+
273
+ await app.inject({
274
+ method: 'POST',
275
+ url: '/admin/auth/admin-grants/bootstrap',
276
+ headers: authHeaders(owner.token, 'summary-bootstrap'),
277
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
278
+ });
279
+ const grant = await app.inject({
280
+ method: 'POST',
281
+ url: '/admin/auth/admin-grants',
282
+ headers: authHeaders(owner.token, 'summary-grant'),
283
+ payload: {
284
+ user_id: observer.userId,
285
+ role: 'admin-observer',
286
+ reason: 'Read-only support access',
287
+ },
288
+ });
289
+
290
+ const firstPage = await app.inject({
291
+ method: 'GET',
292
+ url: '/admin/auth/admin-grants?limit=1',
293
+ headers: {authorization: `Bearer ${observer.token}`},
294
+ });
295
+
296
+ expect(firstPage.statusCode).toBe(200);
297
+ expect(firstPage.json().grants).toHaveLength(1);
298
+ expect(firstPage.json().grants[0]).toEqual({
299
+ grant_id: grant.json().id,
300
+ role: 'admin-observer',
301
+ created_at: expect.any(String),
302
+ revoked_at: null,
303
+ user: {
304
+ id: observer.userId,
305
+ email: observer.email,
306
+ name: 'admin-summary-observer',
307
+ status: 'active',
308
+ },
309
+ });
310
+ expect(firstPage.json().next_cursor).toEqual(expect.any(String));
311
+
312
+ const secondPage = await app.inject({
313
+ method: 'GET',
314
+ url: `/admin/auth/admin-grants?limit=1&cursor=${firstPage.json().next_cursor}`,
315
+ headers: {authorization: `Bearer ${observer.token}`},
316
+ });
317
+
318
+ expect(secondPage.statusCode).toBe(200);
319
+ expect(secondPage.json().grants).toHaveLength(1);
320
+ expect(secondPage.json().grants[0].user.id).toBe(owner.userId);
321
+ expect(secondPage.json().next_cursor).toBeNull();
322
+ });
323
+
324
+ test('rejects ordinary users and unchecked user lookup filters', async () => {
325
+ const ordinary = await createVerifiedSession('admin-lookup-ordinary');
326
+
327
+ const forbidden = await app.inject({
328
+ method: 'GET',
329
+ url: `/admin/auth/users?id=${ordinary.userId}`,
330
+ headers: {authorization: `Bearer ${ordinary.token}`},
331
+ });
332
+ expect(forbidden.statusCode).toBe(403);
333
+
334
+ const missingFilter = await app.inject({
335
+ method: 'GET',
336
+ url: '/admin/auth/users',
337
+ headers: {authorization: `Bearer ${ordinary.token}`},
338
+ });
339
+ expect(missingFilter.statusCode).toBe(400);
340
+
341
+ const multipleFilters = await app.inject({
342
+ method: 'GET',
343
+ url: `/admin/auth/users?id=${ordinary.userId}&email=${encodeURIComponent(ordinary.email)}`,
344
+ headers: {authorization: `Bearer ${ordinary.token}`},
345
+ });
346
+ expect(multipleFilters.statusCode).toBe(400);
347
+ });
348
+
349
+ test('returns 404 for a well-formed but unknown user lookup', async () => {
350
+ const owner = await createVerifiedSession('admin-lookup-unknown-owner');
351
+ const observer = await createVerifiedSession('admin-lookup-unknown-observer');
352
+
353
+ await app.inject({
354
+ method: 'POST',
355
+ url: '/admin/auth/admin-grants/bootstrap',
356
+ headers: authHeaders(owner.token, 'unknown-lookup-bootstrap'),
357
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
358
+ });
359
+ await app.inject({
360
+ method: 'POST',
361
+ url: '/admin/auth/admin-grants',
362
+ headers: authHeaders(owner.token, 'unknown-lookup-grant'),
363
+ payload: {
364
+ user_id: observer.userId,
365
+ role: 'admin-observer',
366
+ reason: 'Support lookup access',
367
+ },
368
+ });
369
+
370
+ const response = await app.inject({
371
+ method: 'GET',
372
+ url: '/admin/auth/users?user_id=00000000-0000-4000-8000-000000000000',
373
+ headers: {authorization: `Bearer ${observer.token}`},
374
+ });
375
+
376
+ expect(response.statusCode).toBe(404);
377
+ expect(response.json().code).toBe('not-found');
378
+ });
379
+
380
+ test('rejects a malformed pagination cursor', async () => {
381
+ const owner = await createVerifiedSession('admin-grants-bad-cursor-owner');
382
+ const observer = await createVerifiedSession('admin-grants-bad-cursor-observer');
383
+
384
+ await app.inject({
385
+ method: 'POST',
386
+ url: '/admin/auth/admin-grants/bootstrap',
387
+ headers: authHeaders(owner.token, 'bad-cursor-bootstrap'),
388
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
389
+ });
390
+ await app.inject({
391
+ method: 'POST',
392
+ url: '/admin/auth/admin-grants',
393
+ headers: authHeaders(owner.token, 'bad-cursor-grant'),
394
+ payload: {
395
+ user_id: observer.userId,
396
+ role: 'admin-observer',
397
+ reason: 'Support lookup access',
398
+ },
399
+ });
400
+
401
+ const response = await app.inject({
402
+ method: 'GET',
403
+ url: '/admin/auth/admin-grants?cursor=not-a-real-cursor',
404
+ headers: {authorization: `Bearer ${observer.token}`},
405
+ });
406
+
407
+ expect(response.statusCode).toBe(400);
408
+ expect(response.json().code).toBe('invalid-cursor');
409
+ });
410
+
411
+ test('rate-limits repeated user lookups by source IP', async () => {
412
+ const owner = await createVerifiedSession('admin-lookup-rate-limit-owner');
413
+ const observer = await createVerifiedSession('admin-lookup-rate-limit-observer');
414
+
415
+ await app.inject({
416
+ method: 'POST',
417
+ url: '/admin/auth/admin-grants/bootstrap',
418
+ headers: authHeaders(owner.token, 'lookup-rate-limit-bootstrap'),
419
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
420
+ });
421
+ await app.inject({
422
+ method: 'POST',
423
+ url: '/admin/auth/admin-grants',
424
+ headers: authHeaders(owner.token, 'lookup-rate-limit-grant'),
425
+ payload: {
426
+ user_id: observer.userId,
427
+ role: 'admin-observer',
428
+ reason: 'Support lookup access',
429
+ },
430
+ });
431
+
432
+ for (let attempt = 0; attempt < 60; attempt += 1) {
433
+ const response = await app.inject({
434
+ method: 'GET',
435
+ url: `/admin/auth/users?user_id=${observer.userId}`,
436
+ headers: {authorization: `Bearer ${observer.token}`},
437
+ });
438
+ expect(response.statusCode).toBe(200);
439
+ }
440
+
441
+ const blocked = await app.inject({
442
+ method: 'GET',
443
+ url: `/admin/auth/users?user_id=${observer.userId}`,
444
+ headers: {authorization: `Bearer ${observer.token}`},
445
+ });
446
+
447
+ expect(blocked.statusCode).toBe(429);
448
+ expect(blocked.json().code).toBe('rate-limited');
449
+ });
450
+
451
+ test('checks the administrator role before consuming the lookup IP bucket', async () => {
452
+ const owner = await createVerifiedSession('admin-lookup-authz-order-owner');
453
+ const observer = await createVerifiedSession('admin-lookup-authz-order-observer');
454
+ const ordinary = await createVerifiedSession('admin-lookup-authz-order-ordinary');
455
+
456
+ await app.inject({
457
+ method: 'POST',
458
+ url: '/admin/auth/admin-grants/bootstrap',
459
+ headers: authHeaders(owner.token, 'authz-order-bootstrap'),
460
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
461
+ });
462
+ await app.inject({
463
+ method: 'POST',
464
+ url: '/admin/auth/admin-grants',
465
+ headers: authHeaders(owner.token, 'authz-order-grant'),
466
+ payload: {
467
+ user_id: observer.userId,
468
+ role: 'admin-observer',
469
+ reason: 'Support lookup access',
470
+ },
471
+ });
472
+
473
+ for (let attempt = 0; attempt < 60; attempt += 1) {
474
+ const response = await app.inject({
475
+ method: 'GET',
476
+ url: `/admin/auth/users?user_id=${ordinary.userId}`,
477
+ headers: {authorization: `Bearer ${ordinary.token}`},
478
+ });
479
+ expect(response.statusCode).toBe(403);
480
+ }
481
+
482
+ const observerResponse = await app.inject({
483
+ method: 'GET',
484
+ url: `/admin/auth/users?user_id=${observer.userId}`,
485
+ headers: {authorization: `Bearer ${observer.token}`},
486
+ });
487
+ expect(observerResponse.statusCode).toBe(200);
488
+ });
489
+
206
490
  test('protects the final active owner and rejects idempotency-key reuse', async () => {
207
491
  const owner = await createVerifiedSession('admin-final-owner');
208
492
  const target = await createVerifiedSession('admin-final-target');
209
493
 
210
494
  await app.inject({
211
495
  method: 'POST',
212
- url: '/admin/v1/auth/admin-grants/bootstrap',
496
+ url: '/admin/auth/admin-grants/bootstrap',
213
497
  headers: authHeaders(owner.token, 'final-bootstrap'),
214
498
  payload: {bootstrap_token: BOOTSTRAP_TOKEN},
215
499
  });
216
500
 
217
501
  const firstGrant = await app.inject({
218
502
  method: 'POST',
219
- url: '/admin/v1/auth/admin-grants',
503
+ url: '/admin/auth/admin-grants',
220
504
  headers: authHeaders(owner.token, 'role-key'),
221
505
  payload: {user_id: target.userId, role: 'admin-observer', reason: 'Initial review'},
222
506
  });
@@ -224,7 +508,7 @@ describe('Auth administration routes', () => {
224
508
 
225
509
  const reusedKey = await app.inject({
226
510
  method: 'POST',
227
- url: '/admin/v1/auth/admin-grants',
511
+ url: '/admin/auth/admin-grants',
228
512
  headers: authHeaders(owner.token, 'role-key'),
229
513
  payload: {user_id: target.userId, role: 'admin-operator', reason: 'Different command'},
230
514
  });
@@ -233,16 +517,16 @@ describe('Auth administration routes', () => {
233
517
 
234
518
  const finalOwnerRevoke = await app.inject({
235
519
  method: 'DELETE',
236
- url: `/admin/v1/auth/admin-grants/${
520
+ url: `/admin/auth/admin-grants/${
237
521
  (
238
522
  await app.inject({
239
523
  method: 'GET',
240
- url: '/admin/v1/auth/admin-grants',
524
+ url: '/admin/auth/admin-grants',
241
525
  headers: {authorization: `Bearer ${owner.token}`},
242
526
  })
243
527
  )
244
528
  .json()
245
- .grants.find((grant: {user_id: string}) => grant.user_id === owner.userId).id
529
+ .grants.find((grant: {user: {id: string}}) => grant.user.id === owner.userId).grant_id
246
530
  }`,
247
531
  headers: authHeaders(owner.token, 'revoke-final-owner'),
248
532
  payload: {reason: 'Attempt to remove final owner'},
@@ -1,23 +1,33 @@
1
1
  import {AUTH_USER} from '@shipfox/api-auth-context';
2
2
  import {
3
+ administratorUserLookupQuerySchema,
4
+ administratorUserSummarySchema,
3
5
  bootstrapAdminOwnerBodySchema,
4
6
  bootstrapAdminOwnerResponseSchema,
5
7
  grantAdminRoleBodySchema,
6
8
  grantAdminRoleResponseSchema,
9
+ listAdminGrantsQuerySchema,
7
10
  listAdminGrantsResponseSchema,
8
11
  revokeAdminGrantBodySchema,
9
12
  revokeAdminGrantResponseSchema,
10
13
  } from '@shipfox/api-auth-dto';
14
+ import {decodeTimestampIdCursor, encodeTimestampIdCursor} from '@shipfox/node-drizzle';
11
15
  import {ClientError, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
12
16
  import type {FastifyRequest} from 'fastify';
13
17
  import {z} from 'zod';
18
+ import {requireAdminRole} from '#core/admin-role.js';
14
19
  import {
15
20
  bootstrapFirstAdminOwner,
21
+ findAdministratorUserSummary,
16
22
  grantAdministratorRole,
17
- listAdministratorGrants,
23
+ listAdministratorGrantSummaries,
18
24
  revokeAdministratorGrant,
19
25
  } from '#core/administration.js';
20
26
  import type {AdminGrant} from '#core/entities/admin-grant.js';
27
+ import type {
28
+ AdministratorGrantSummary,
29
+ AdministratorUserSummary,
30
+ } from '#core/entities/administrator-read-model.js';
21
31
  import {
22
32
  AdminBootstrapClosedError,
23
33
  AdminGrantAlreadyExistsError,
@@ -52,6 +62,10 @@ function requireIdempotencyKey(request: FastifyRequest): string {
52
62
  return key;
53
63
  }
54
64
 
65
+ async function requireAdministratorObserver(request: FastifyRequest): Promise<void> {
66
+ await requireAdminRole({userId: requireActorId(request), minimumRole: 'admin-observer'});
67
+ }
68
+
55
69
  function toAdminGrantDto(grant: AdminGrant) {
56
70
  return {
57
71
  id: grant.id,
@@ -63,9 +77,31 @@ function toAdminGrantDto(grant: AdminGrant) {
63
77
  };
64
78
  }
65
79
 
80
+ function toAdministratorUserSummaryDto(user: AdministratorUserSummary) {
81
+ return {
82
+ id: user.id,
83
+ email: user.email,
84
+ name: user.name,
85
+ status: user.status,
86
+ email_verified_at: user.emailVerifiedAt?.toISOString() ?? null,
87
+ created_at: user.createdAt.toISOString(),
88
+ admin_role: user.adminRole,
89
+ };
90
+ }
91
+
92
+ function toAdministratorGrantSummaryDto(grant: AdministratorGrantSummary) {
93
+ return {
94
+ grant_id: grant.grantId,
95
+ role: grant.role,
96
+ created_at: grant.createdAt.toISOString(),
97
+ revoked_at: grant.revokedAt?.toISOString() ?? null,
98
+ user: grant.user,
99
+ };
100
+ }
101
+
66
102
  function translateAdministrationError(error: unknown): never {
67
103
  if (error instanceof AdminRoleRequiredError) {
68
- throw new ClientError('Administrator owner role required', 'forbidden', {
104
+ throw new ClientError('Administrator role required', 'forbidden', {
69
105
  status: 403,
70
106
  details: {required_role: error.minimumRole},
71
107
  });
@@ -132,14 +168,53 @@ const bootstrapRoute = defineRoute({
132
168
  const listRoute = defineRoute({
133
169
  method: 'GET',
134
170
  path: '/',
135
- description: 'List local administrator grants.',
136
- schema: {response: {200: listAdminGrantsResponseSchema}},
171
+ description: 'List bounded local administrator grant summaries.',
172
+ schema: {
173
+ querystring: listAdminGrantsQuerySchema,
174
+ response: {200: listAdminGrantsResponseSchema},
175
+ },
137
176
  errorHandler: translateAdministrationError,
138
- handler: async (request) => ({
139
- grants: (await listAdministratorGrants({actorId: requireActorId(request)})).map(
140
- toAdminGrantDto,
141
- ),
142
- }),
177
+ handler: async (request) => {
178
+ const {limit, cursor} = request.query;
179
+ const decodedCursor = decodeTimestampIdCursor(cursor);
180
+ if (cursor && !decodedCursor) {
181
+ throw new ClientError('Invalid cursor', 'invalid-cursor', {status: 400});
182
+ }
183
+
184
+ const result = await listAdministratorGrantSummaries({
185
+ actorId: requireActorId(request),
186
+ limit,
187
+ ...(decodedCursor ? {cursor: decodedCursor} : {}),
188
+ });
189
+ return {
190
+ grants: result.grants.map(toAdministratorGrantSummaryDto),
191
+ next_cursor: result.nextCursor ? encodeTimestampIdCursor(result.nextCursor) : null,
192
+ };
193
+ },
194
+ });
195
+
196
+ const userLookupRoute = defineRoute({
197
+ method: 'GET',
198
+ path: '/',
199
+ description: 'Find one administrator-safe user summary by exact ID or email.',
200
+ schema: {
201
+ querystring: administratorUserLookupQuerySchema,
202
+ response: {200: administratorUserSummarySchema},
203
+ },
204
+ preHandler: [requireAdministratorObserver, createAuthIpRateLimitPreHandler('lookup')],
205
+ errorHandler: translateAdministrationError,
206
+ handler: async (request) => {
207
+ const {id, user_id: userId, email} = request.query;
208
+ const lookupId = id ?? userId;
209
+ const actorId = requireActorId(request);
210
+ const user = lookupId
211
+ ? await findAdministratorUserSummary({actorId, id: lookupId})
212
+ : email
213
+ ? await findAdministratorUserSummary({actorId, email})
214
+ : undefined;
215
+ if (!user) throw new UserNotFoundError(lookupId ?? email ?? 'unknown');
216
+ return toAdministratorUserSummaryDto(user);
217
+ },
143
218
  });
144
219
 
145
220
  const grantRoute = defineRoute({
@@ -189,7 +264,13 @@ const revokeRoute = defineRoute({
189
264
  });
190
265
 
191
266
  export const administrationRoutes: RouteGroup = {
192
- prefix: '/admin/v1/auth/admin-grants',
267
+ prefix: '/admin/auth/admin-grants',
193
268
  auth: AUTH_USER,
194
269
  routes: [bootstrapRoute, listRoute, grantRoute, revokeRoute],
195
270
  };
271
+
272
+ export const administrationUserRoutes: RouteGroup = {
273
+ prefix: '/admin/auth/users',
274
+ auth: AUTH_USER,
275
+ routes: [userLookupRoute],
276
+ };
@@ -23,6 +23,9 @@ const policies: Record<
23
23
  bootstrap: {
24
24
  ip: {limit: 5, windowSeconds: 15 * 60},
25
25
  },
26
+ lookup: {
27
+ ip: {limit: 60, windowSeconds: 5 * 60},
28
+ },
26
29
  };
27
30
 
28
31
  interface EmailBody {
package/test/routes.ts CHANGED
@@ -8,7 +8,10 @@ import {and, desc, eq, sql} from 'drizzle-orm';
8
8
  import {db} from '#db/db.js';
9
9
  import {authOutbox} from '#db/schema/outbox.js';
10
10
  import {createJwtAuthMethod} from '#presentation/auth/jwt-auth.js';
11
- import {administrationRoutes} from '#presentation/routes/administration.js';
11
+ import {
12
+ administrationRoutes,
13
+ administrationUserRoutes,
14
+ } from '#presentation/routes/administration.js';
12
15
  import {buildAuthRoutes} from '#presentation/routes/index.js';
13
16
 
14
17
  const testConfig = vi.hoisted(
@@ -153,7 +156,7 @@ export async function createAuthTestApp(params?: {
153
156
  }): Promise<FastifyInstance> {
154
157
  const appConfig: AppConfig = {
155
158
  auth: [createJwtAuthMethod()],
156
- routes: [buildAuthRoutes(true, workspaces), administrationRoutes],
159
+ routes: [buildAuthRoutes(true, workspaces), administrationRoutes, administrationUserRoutes],
157
160
  swagger: false,
158
161
  };
159
162
  if (params?.fastifyOptions) appConfig.fastifyOptions = params.fastifyOptions;