@shipfox/api-auth 9.3.0 → 10.1.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 +48 -0
  3. package/README.md +14 -3
  4. package/dist/core/administration.d.ts +19 -2
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +37 -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 +25 -1
  13. package/dist/db/admin-grants.d.ts.map +1 -1
  14. package/dist/db/admin-grants.js +51 -14
  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 +5 -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 +2 -0
  28. package/dist/presentation/routes/administration.d.ts.map +1 -1
  29. package/dist/presentation/routes/administration.js +113 -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 +12 -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 +50 -4
  37. package/src/core/entities/administrator-read-model.ts +23 -0
  38. package/src/db/admin-grants.test.ts +12 -2
  39. package/src/db/admin-grants.ts +85 -39
  40. package/src/db/admin-users.ts +58 -0
  41. package/src/index.test.ts +4 -1
  42. package/src/index.ts +7 -2
  43. package/src/metrics/instance.ts +6 -1
  44. package/src/presentation/routes/administration.test.ts +432 -20
  45. package/src/presentation/routes/administration.ts +109 -10
  46. package/src/presentation/routes/rate-limit.ts +6 -0
  47. package/test/routes.ts +11 -2
  48. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,8 +1,10 @@
1
1
  import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
2
2
  import {sql} from 'drizzle-orm';
3
3
  import type {FastifyInstance} from 'fastify';
4
+ import {type AuthRateLimitAction, hashAuthRateLimitIdentifier} from '#core/rate-limit.js';
4
5
  import {db} from '#db/db.js';
5
6
  import {authOutbox} from '#db/schema/outbox.js';
7
+ import {authRateLimits} from '#db/schema/rate-limits.js';
6
8
  import {createAuthTestApp, createVerifiedSession, resetCapturedMail} from '#test/routes.js';
7
9
 
8
10
  const BOOTSTRAP_TOKEN = 'test-bootstrap-token';
@@ -20,6 +22,30 @@ function authHeaders(token: string, idempotencyKey: string) {
20
22
  };
21
23
  }
22
24
 
25
+ async function seedExhaustedIpBucket(params: {
26
+ action: AuthRateLimitAction;
27
+ identifier: string;
28
+ limit: number;
29
+ windowSeconds: number;
30
+ }): Promise<void> {
31
+ const windowMs = params.windowSeconds * 1000;
32
+ const windowStart = new Date(Math.floor(Date.now() / windowMs) * windowMs);
33
+ await db()
34
+ .insert(authRateLimits)
35
+ .values({
36
+ action: params.action,
37
+ scope: 'ip',
38
+ identifierHmac: hashAuthRateLimitIdentifier({
39
+ action: params.action,
40
+ scope: 'ip',
41
+ identifier: params.identifier,
42
+ }),
43
+ windowStart,
44
+ count: params.limit,
45
+ expiresAt: new Date(windowStart.getTime() + windowMs),
46
+ });
47
+ }
48
+
23
49
  describe('Auth administration routes', () => {
24
50
  let app: FastifyInstance;
25
51
 
@@ -40,7 +66,7 @@ describe('Auth administration routes', () => {
40
66
  test('requires an authenticated session for bootstrap', async () => {
41
67
  const response = await app.inject({
42
68
  method: 'POST',
43
- url: '/admin/v1/auth/admin-grants/bootstrap',
69
+ url: '/admin/auth/admin-grants/bootstrap',
44
70
  headers: {'idempotency-key': 'anonymous-bootstrap'},
45
71
  payload: {bootstrap_token: BOOTSTRAP_TOKEN},
46
72
  });
@@ -48,12 +74,93 @@ describe('Auth administration routes', () => {
48
74
  expect(response.statusCode).toBe(401);
49
75
  });
50
76
 
77
+ test('requires an authenticated session for bootstrap state', async () => {
78
+ const response = await app.inject({
79
+ method: 'GET',
80
+ url: '/admin/auth/bootstrap-state',
81
+ });
82
+
83
+ expect(response.statusCode).toBe(401);
84
+ });
85
+
86
+ test('reports only available or closed bootstrap state', async () => {
87
+ const account = await createVerifiedSession('admin-bootstrap-state');
88
+
89
+ const available = await app.inject({
90
+ method: 'GET',
91
+ url: '/admin/auth/bootstrap-state',
92
+ headers: {authorization: `Bearer ${account.token}`},
93
+ });
94
+
95
+ expect(available.statusCode).toBe(200);
96
+ expect(available.json()).toEqual({state: 'available'});
97
+
98
+ const bootstrap = await app.inject({
99
+ method: 'POST',
100
+ url: '/admin/auth/admin-grants/bootstrap',
101
+ headers: authHeaders(account.token, 'bootstrap-state-bootstrap'),
102
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
103
+ });
104
+ expect(bootstrap.statusCode).toBe(201);
105
+
106
+ const closed = await app.inject({
107
+ method: 'GET',
108
+ url: '/admin/auth/bootstrap-state',
109
+ headers: {authorization: `Bearer ${account.token}`},
110
+ });
111
+
112
+ expect(closed.statusCode).toBe(200);
113
+ expect(closed.json()).toEqual({state: 'closed'});
114
+ expect(JSON.stringify(closed.json())).not.toContain(BOOTSTRAP_TOKEN);
115
+ });
116
+
117
+ test('keeps bootstrap-state reads separate from the bootstrap write limit', async () => {
118
+ const account = await createVerifiedSession('admin-bootstrap-state-rate-limit');
119
+ const ip = '127.0.0.1';
120
+
121
+ await seedExhaustedIpBucket({
122
+ action: 'bootstrap',
123
+ identifier: ip,
124
+ limit: 5,
125
+ windowSeconds: 15 * 60,
126
+ });
127
+ const state = await app.inject({
128
+ method: 'GET',
129
+ url: '/admin/auth/bootstrap-state',
130
+ headers: {authorization: `Bearer ${account.token}`},
131
+ });
132
+ expect(state.statusCode).toBe(200);
133
+
134
+ await resetAdministrationState();
135
+ await seedExhaustedIpBucket({
136
+ action: 'bootstrap-state',
137
+ identifier: ip,
138
+ limit: 60,
139
+ windowSeconds: 5 * 60,
140
+ });
141
+ const blocked = await app.inject({
142
+ method: 'GET',
143
+ url: '/admin/auth/bootstrap-state',
144
+ headers: {authorization: `Bearer ${account.token}`},
145
+ });
146
+ expect(blocked.statusCode).toBe(429);
147
+ });
148
+
149
+ test('does not register the removed versioned administration namespace', async () => {
150
+ const response = await app.inject({
151
+ method: 'GET',
152
+ url: '/admin/v1/auth/users?user_id=00000000-0000-4000-8000-000000000000',
153
+ });
154
+
155
+ expect(response.statusCode).toBe(404);
156
+ });
157
+
51
158
  test('rejects an invalid bootstrap token without writing a grant or event', async () => {
52
159
  const account = await createVerifiedSession('admin-bootstrap-invalid');
53
160
 
54
161
  const response = await app.inject({
55
162
  method: 'POST',
56
- url: '/admin/v1/auth/admin-grants/bootstrap',
163
+ url: '/admin/auth/admin-grants/bootstrap',
57
164
  headers: authHeaders(account.token, 'invalid-bootstrap'),
58
165
  payload: {bootstrap_token: 'wrong-token'},
59
166
  });
@@ -69,7 +176,7 @@ describe('Auth administration routes', () => {
69
176
  for (let attempt = 0; attempt < 5; attempt += 1) {
70
177
  const response = await app.inject({
71
178
  method: 'POST',
72
- url: '/admin/v1/auth/admin-grants/bootstrap',
179
+ url: '/admin/auth/admin-grants/bootstrap',
73
180
  headers: authHeaders(account.token, `bootstrap-rate-limit-${attempt}`),
74
181
  payload: {bootstrap_token: 'wrong-token'},
75
182
  });
@@ -78,7 +185,7 @@ describe('Auth administration routes', () => {
78
185
 
79
186
  const blocked = await app.inject({
80
187
  method: 'POST',
81
- url: '/admin/v1/auth/admin-grants/bootstrap',
188
+ url: '/admin/auth/admin-grants/bootstrap',
82
189
  headers: authHeaders(account.token, 'bootstrap-rate-limit-blocked'),
83
190
  payload: {bootstrap_token: 'wrong-token'},
84
191
  });
@@ -92,7 +199,7 @@ describe('Auth administration routes', () => {
92
199
 
93
200
  const response = await app.inject({
94
201
  method: 'POST',
95
- url: '/admin/v1/auth/admin-grants/bootstrap',
202
+ url: '/admin/auth/admin-grants/bootstrap',
96
203
  headers: authHeaders(first.token, 'bootstrap-first'),
97
204
  payload: {bootstrap_token: BOOTSTRAP_TOKEN},
98
205
  });
@@ -102,7 +209,7 @@ describe('Auth administration routes', () => {
102
209
 
103
210
  const repeated = await app.inject({
104
211
  method: 'POST',
105
- url: '/admin/v1/auth/admin-grants/bootstrap',
212
+ url: '/admin/auth/admin-grants/bootstrap',
106
213
  headers: authHeaders(first.token, 'bootstrap-first'),
107
214
  payload: {bootstrap_token: BOOTSTRAP_TOKEN},
108
215
  });
@@ -111,7 +218,7 @@ describe('Auth administration routes', () => {
111
218
 
112
219
  const secondAttempt = await app.inject({
113
220
  method: 'POST',
114
- url: '/admin/v1/auth/admin-grants/bootstrap',
221
+ url: '/admin/auth/admin-grants/bootstrap',
115
222
  headers: authHeaders(second.token, 'bootstrap-second'),
116
223
  payload: {bootstrap_token: BOOTSTRAP_TOKEN},
117
224
  });
@@ -131,13 +238,43 @@ describe('Auth administration routes', () => {
131
238
  expect(JSON.stringify(events[0]?.payload)).not.toContain(BOOTSTRAP_TOKEN);
132
239
  });
133
240
 
241
+ test('serializes concurrent bootstrap attempts to one active first owner', async () => {
242
+ const first = await createVerifiedSession('admin-bootstrap-race-first');
243
+ const second = await createVerifiedSession('admin-bootstrap-race-second');
244
+
245
+ const responses = await Promise.all(
246
+ [first, second].map((account, index) =>
247
+ app.inject({
248
+ method: 'POST',
249
+ url: '/admin/auth/admin-grants/bootstrap',
250
+ headers: authHeaders(account.token, `bootstrap-race-${index}`),
251
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
252
+ }),
253
+ ),
254
+ );
255
+
256
+ expect(responses.map((response) => response.statusCode).sort()).toEqual([201, 409]);
257
+ const activeOwnerRoles = await Promise.all(
258
+ [first, second].map(async (account) => {
259
+ const response = await app.inject({
260
+ method: 'GET',
261
+ url: `/admin/auth/users?user_id=${account.userId}`,
262
+ headers: {authorization: `Bearer ${account.token}`},
263
+ });
264
+ return response.statusCode === 200 && response.json().admin_role === 'admin-owner';
265
+ }),
266
+ );
267
+
268
+ expect(activeOwnerRoles.filter(Boolean)).toHaveLength(1);
269
+ });
270
+
134
271
  test('lets an owner list, grant, and revoke roles with idempotent audited mutations', async () => {
135
272
  const owner = await createVerifiedSession('admin-grant-owner');
136
273
  const target = await createVerifiedSession('admin-grant-target');
137
274
 
138
275
  const bootstrap = await app.inject({
139
276
  method: 'POST',
140
- url: '/admin/v1/auth/admin-grants/bootstrap',
277
+ url: '/admin/auth/admin-grants/bootstrap',
141
278
  headers: authHeaders(owner.token, 'grant-bootstrap'),
142
279
  payload: {bootstrap_token: BOOTSTRAP_TOKEN},
143
280
  });
@@ -145,7 +282,7 @@ describe('Auth administration routes', () => {
145
282
 
146
283
  const grant = await app.inject({
147
284
  method: 'POST',
148
- url: '/admin/v1/auth/admin-grants',
285
+ url: '/admin/auth/admin-grants',
149
286
  headers: authHeaders(owner.token, 'grant-observer'),
150
287
  payload: {
151
288
  user_id: target.userId,
@@ -157,7 +294,7 @@ describe('Auth administration routes', () => {
157
294
 
158
295
  const repeatedGrant = await app.inject({
159
296
  method: 'POST',
160
- url: '/admin/v1/auth/admin-grants',
297
+ url: '/admin/auth/admin-grants',
161
298
  headers: authHeaders(owner.token, 'grant-observer'),
162
299
  payload: {
163
300
  user_id: target.userId,
@@ -170,17 +307,19 @@ describe('Auth administration routes', () => {
170
307
 
171
308
  const grants = await app.inject({
172
309
  method: 'GET',
173
- url: '/admin/v1/auth/admin-grants',
310
+ url: '/admin/auth/admin-grants',
174
311
  headers: {authorization: `Bearer ${owner.token}`},
175
312
  });
176
313
  expect(grants.statusCode).toBe(200);
177
314
  expect(grants.json().grants).toEqual(
178
- expect.arrayContaining([expect.objectContaining({user_id: target.userId})]),
315
+ expect.arrayContaining([
316
+ expect.objectContaining({user: expect.objectContaining({id: target.userId})}),
317
+ ]),
179
318
  );
180
319
 
181
320
  const revoked = await app.inject({
182
321
  method: 'DELETE',
183
- url: `/admin/v1/auth/admin-grants/${grant.json().id}`,
322
+ url: `/admin/auth/admin-grants/${grant.json().id}`,
184
323
  headers: authHeaders(owner.token, 'revoke-observer'),
185
324
  payload: {reason: 'Support investigation complete'},
186
325
  });
@@ -189,7 +328,7 @@ describe('Auth administration routes', () => {
189
328
 
190
329
  const repeatedRevoke = await app.inject({
191
330
  method: 'DELETE',
192
- url: `/admin/v1/auth/admin-grants/${grant.json().id}`,
331
+ url: `/admin/auth/admin-grants/${grant.json().id}`,
193
332
  headers: authHeaders(owner.token, 'revoke-observer'),
194
333
  payload: {reason: 'Support investigation complete'},
195
334
  });
@@ -203,20 +342,293 @@ describe('Auth administration routes', () => {
203
342
  );
204
343
  });
205
344
 
345
+ test('lets an observer find exact IDs and normalized emails without exposing user secrets', async () => {
346
+ const owner = await createVerifiedSession('admin-user-lookup-owner');
347
+ const observer = await createVerifiedSession('admin-user-lookup-observer');
348
+
349
+ await app.inject({
350
+ method: 'POST',
351
+ url: '/admin/auth/admin-grants/bootstrap',
352
+ headers: authHeaders(owner.token, 'user-lookup-bootstrap'),
353
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
354
+ });
355
+ await app.inject({
356
+ method: 'POST',
357
+ url: '/admin/auth/admin-grants',
358
+ headers: authHeaders(owner.token, 'user-lookup-grant'),
359
+ payload: {
360
+ user_id: observer.userId,
361
+ role: 'admin-observer',
362
+ reason: 'Support lookup access',
363
+ },
364
+ });
365
+
366
+ const byId = await app.inject({
367
+ method: 'GET',
368
+ url: `/admin/auth/users?user_id=${observer.userId}`,
369
+ headers: {authorization: `Bearer ${observer.token}`},
370
+ });
371
+
372
+ expect(byId.statusCode).toBe(200);
373
+ expect(byId.json()).toEqual({
374
+ id: observer.userId,
375
+ email: observer.email,
376
+ name: 'admin-user-lookup-observer',
377
+ status: 'active',
378
+ email_verified_at: expect.any(String),
379
+ created_at: expect.any(String),
380
+ admin_role: 'admin-observer',
381
+ });
382
+ expect(byId.json()).not.toHaveProperty('hashed_password');
383
+ expect(byId.json()).not.toHaveProperty('sessions');
384
+ expect(byId.json()).not.toHaveProperty('provider_payload');
385
+
386
+ const byEmail = await app.inject({
387
+ method: 'GET',
388
+ url: `/admin/auth/users?email=${encodeURIComponent(` ${observer.email.toUpperCase()} `)}`,
389
+ headers: {authorization: `Bearer ${observer.token}`},
390
+ });
391
+
392
+ expect(byEmail.statusCode).toBe(200);
393
+ expect(byEmail.json().id).toBe(observer.userId);
394
+ expect(byEmail.json().email).toBe(observer.email);
395
+ });
396
+
397
+ test('bounds and deterministically paginates administrator grant summaries for observers', async () => {
398
+ const owner = await createVerifiedSession('admin-summary-owner');
399
+ const observer = await createVerifiedSession('admin-summary-observer');
400
+
401
+ await app.inject({
402
+ method: 'POST',
403
+ url: '/admin/auth/admin-grants/bootstrap',
404
+ headers: authHeaders(owner.token, 'summary-bootstrap'),
405
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
406
+ });
407
+ const grant = await app.inject({
408
+ method: 'POST',
409
+ url: '/admin/auth/admin-grants',
410
+ headers: authHeaders(owner.token, 'summary-grant'),
411
+ payload: {
412
+ user_id: observer.userId,
413
+ role: 'admin-observer',
414
+ reason: 'Read-only support access',
415
+ },
416
+ });
417
+
418
+ const firstPage = await app.inject({
419
+ method: 'GET',
420
+ url: '/admin/auth/admin-grants?limit=1',
421
+ headers: {authorization: `Bearer ${observer.token}`},
422
+ });
423
+
424
+ expect(firstPage.statusCode).toBe(200);
425
+ expect(firstPage.json().grants).toHaveLength(1);
426
+ expect(firstPage.json().grants[0]).toEqual({
427
+ grant_id: grant.json().id,
428
+ role: 'admin-observer',
429
+ created_at: expect.any(String),
430
+ revoked_at: null,
431
+ user: {
432
+ id: observer.userId,
433
+ email: observer.email,
434
+ name: 'admin-summary-observer',
435
+ status: 'active',
436
+ },
437
+ });
438
+ expect(firstPage.json().next_cursor).toEqual(expect.any(String));
439
+
440
+ const secondPage = await app.inject({
441
+ method: 'GET',
442
+ url: `/admin/auth/admin-grants?limit=1&cursor=${firstPage.json().next_cursor}`,
443
+ headers: {authorization: `Bearer ${observer.token}`},
444
+ });
445
+
446
+ expect(secondPage.statusCode).toBe(200);
447
+ expect(secondPage.json().grants).toHaveLength(1);
448
+ expect(secondPage.json().grants[0].user.id).toBe(owner.userId);
449
+ expect(secondPage.json().next_cursor).toBeNull();
450
+ });
451
+
452
+ test('rejects ordinary users and unchecked user lookup filters', async () => {
453
+ const ordinary = await createVerifiedSession('admin-lookup-ordinary');
454
+
455
+ const forbidden = await app.inject({
456
+ method: 'GET',
457
+ url: `/admin/auth/users?id=${ordinary.userId}`,
458
+ headers: {authorization: `Bearer ${ordinary.token}`},
459
+ });
460
+ expect(forbidden.statusCode).toBe(403);
461
+
462
+ const missingFilter = await app.inject({
463
+ method: 'GET',
464
+ url: '/admin/auth/users',
465
+ headers: {authorization: `Bearer ${ordinary.token}`},
466
+ });
467
+ expect(missingFilter.statusCode).toBe(400);
468
+
469
+ const multipleFilters = await app.inject({
470
+ method: 'GET',
471
+ url: `/admin/auth/users?id=${ordinary.userId}&email=${encodeURIComponent(ordinary.email)}`,
472
+ headers: {authorization: `Bearer ${ordinary.token}`},
473
+ });
474
+ expect(multipleFilters.statusCode).toBe(400);
475
+ });
476
+
477
+ test('returns 404 for a well-formed but unknown user lookup', async () => {
478
+ const owner = await createVerifiedSession('admin-lookup-unknown-owner');
479
+ const observer = await createVerifiedSession('admin-lookup-unknown-observer');
480
+
481
+ await app.inject({
482
+ method: 'POST',
483
+ url: '/admin/auth/admin-grants/bootstrap',
484
+ headers: authHeaders(owner.token, 'unknown-lookup-bootstrap'),
485
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
486
+ });
487
+ await app.inject({
488
+ method: 'POST',
489
+ url: '/admin/auth/admin-grants',
490
+ headers: authHeaders(owner.token, 'unknown-lookup-grant'),
491
+ payload: {
492
+ user_id: observer.userId,
493
+ role: 'admin-observer',
494
+ reason: 'Support lookup access',
495
+ },
496
+ });
497
+
498
+ const response = await app.inject({
499
+ method: 'GET',
500
+ url: '/admin/auth/users?user_id=00000000-0000-4000-8000-000000000000',
501
+ headers: {authorization: `Bearer ${observer.token}`},
502
+ });
503
+
504
+ expect(response.statusCode).toBe(404);
505
+ expect(response.json().code).toBe('not-found');
506
+ });
507
+
508
+ test('rejects a malformed pagination cursor', async () => {
509
+ const owner = await createVerifiedSession('admin-grants-bad-cursor-owner');
510
+ const observer = await createVerifiedSession('admin-grants-bad-cursor-observer');
511
+
512
+ await app.inject({
513
+ method: 'POST',
514
+ url: '/admin/auth/admin-grants/bootstrap',
515
+ headers: authHeaders(owner.token, 'bad-cursor-bootstrap'),
516
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
517
+ });
518
+ await app.inject({
519
+ method: 'POST',
520
+ url: '/admin/auth/admin-grants',
521
+ headers: authHeaders(owner.token, 'bad-cursor-grant'),
522
+ payload: {
523
+ user_id: observer.userId,
524
+ role: 'admin-observer',
525
+ reason: 'Support lookup access',
526
+ },
527
+ });
528
+
529
+ const response = await app.inject({
530
+ method: 'GET',
531
+ url: '/admin/auth/admin-grants?cursor=not-a-real-cursor',
532
+ headers: {authorization: `Bearer ${observer.token}`},
533
+ });
534
+
535
+ expect(response.statusCode).toBe(400);
536
+ expect(response.json().code).toBe('invalid-cursor');
537
+ });
538
+
539
+ test('rate-limits repeated user lookups by source IP', async () => {
540
+ const owner = await createVerifiedSession('admin-lookup-rate-limit-owner');
541
+ const observer = await createVerifiedSession('admin-lookup-rate-limit-observer');
542
+
543
+ await app.inject({
544
+ method: 'POST',
545
+ url: '/admin/auth/admin-grants/bootstrap',
546
+ headers: authHeaders(owner.token, 'lookup-rate-limit-bootstrap'),
547
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
548
+ });
549
+ await app.inject({
550
+ method: 'POST',
551
+ url: '/admin/auth/admin-grants',
552
+ headers: authHeaders(owner.token, 'lookup-rate-limit-grant'),
553
+ payload: {
554
+ user_id: observer.userId,
555
+ role: 'admin-observer',
556
+ reason: 'Support lookup access',
557
+ },
558
+ });
559
+
560
+ for (let attempt = 0; attempt < 60; attempt += 1) {
561
+ const response = await app.inject({
562
+ method: 'GET',
563
+ url: `/admin/auth/users?user_id=${observer.userId}`,
564
+ headers: {authorization: `Bearer ${observer.token}`},
565
+ });
566
+ expect(response.statusCode).toBe(200);
567
+ }
568
+
569
+ const blocked = await app.inject({
570
+ method: 'GET',
571
+ url: `/admin/auth/users?user_id=${observer.userId}`,
572
+ headers: {authorization: `Bearer ${observer.token}`},
573
+ });
574
+
575
+ expect(blocked.statusCode).toBe(429);
576
+ expect(blocked.json().code).toBe('rate-limited');
577
+ });
578
+
579
+ test('checks the administrator role before consuming the lookup IP bucket', async () => {
580
+ const owner = await createVerifiedSession('admin-lookup-authz-order-owner');
581
+ const observer = await createVerifiedSession('admin-lookup-authz-order-observer');
582
+ const ordinary = await createVerifiedSession('admin-lookup-authz-order-ordinary');
583
+
584
+ await app.inject({
585
+ method: 'POST',
586
+ url: '/admin/auth/admin-grants/bootstrap',
587
+ headers: authHeaders(owner.token, 'authz-order-bootstrap'),
588
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
589
+ });
590
+ await app.inject({
591
+ method: 'POST',
592
+ url: '/admin/auth/admin-grants',
593
+ headers: authHeaders(owner.token, 'authz-order-grant'),
594
+ payload: {
595
+ user_id: observer.userId,
596
+ role: 'admin-observer',
597
+ reason: 'Support lookup access',
598
+ },
599
+ });
600
+
601
+ for (let attempt = 0; attempt < 60; attempt += 1) {
602
+ const response = await app.inject({
603
+ method: 'GET',
604
+ url: `/admin/auth/users?user_id=${ordinary.userId}`,
605
+ headers: {authorization: `Bearer ${ordinary.token}`},
606
+ });
607
+ expect(response.statusCode).toBe(403);
608
+ }
609
+
610
+ const observerResponse = await app.inject({
611
+ method: 'GET',
612
+ url: `/admin/auth/users?user_id=${observer.userId}`,
613
+ headers: {authorization: `Bearer ${observer.token}`},
614
+ });
615
+ expect(observerResponse.statusCode).toBe(200);
616
+ });
617
+
206
618
  test('protects the final active owner and rejects idempotency-key reuse', async () => {
207
619
  const owner = await createVerifiedSession('admin-final-owner');
208
620
  const target = await createVerifiedSession('admin-final-target');
209
621
 
210
622
  await app.inject({
211
623
  method: 'POST',
212
- url: '/admin/v1/auth/admin-grants/bootstrap',
624
+ url: '/admin/auth/admin-grants/bootstrap',
213
625
  headers: authHeaders(owner.token, 'final-bootstrap'),
214
626
  payload: {bootstrap_token: BOOTSTRAP_TOKEN},
215
627
  });
216
628
 
217
629
  const firstGrant = await app.inject({
218
630
  method: 'POST',
219
- url: '/admin/v1/auth/admin-grants',
631
+ url: '/admin/auth/admin-grants',
220
632
  headers: authHeaders(owner.token, 'role-key'),
221
633
  payload: {user_id: target.userId, role: 'admin-observer', reason: 'Initial review'},
222
634
  });
@@ -224,7 +636,7 @@ describe('Auth administration routes', () => {
224
636
 
225
637
  const reusedKey = await app.inject({
226
638
  method: 'POST',
227
- url: '/admin/v1/auth/admin-grants',
639
+ url: '/admin/auth/admin-grants',
228
640
  headers: authHeaders(owner.token, 'role-key'),
229
641
  payload: {user_id: target.userId, role: 'admin-operator', reason: 'Different command'},
230
642
  });
@@ -233,16 +645,16 @@ describe('Auth administration routes', () => {
233
645
 
234
646
  const finalOwnerRevoke = await app.inject({
235
647
  method: 'DELETE',
236
- url: `/admin/v1/auth/admin-grants/${
648
+ url: `/admin/auth/admin-grants/${
237
649
  (
238
650
  await app.inject({
239
651
  method: 'GET',
240
- url: '/admin/v1/auth/admin-grants',
652
+ url: '/admin/auth/admin-grants',
241
653
  headers: {authorization: `Bearer ${owner.token}`},
242
654
  })
243
655
  )
244
656
  .json()
245
- .grants.find((grant: {user_id: string}) => grant.user_id === owner.userId).id
657
+ .grants.find((grant: {user: {id: string}}) => grant.user.id === owner.userId).grant_id
246
658
  }`,
247
659
  headers: authHeaders(owner.token, 'revoke-final-owner'),
248
660
  payload: {reason: 'Attempt to remove final owner'},