@shipfox/api-auth 9.2.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 (82) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +56 -0
  3. package/README.md +32 -8
  4. package/dist/config.d.ts +1 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +4 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/core/administration.d.ts +43 -0
  9. package/dist/core/administration.d.ts.map +1 -0
  10. package/dist/core/administration.js +152 -0
  11. package/dist/core/administration.js.map +1 -0
  12. package/dist/core/auth.d.ts.map +1 -1
  13. package/dist/core/auth.js +2 -5
  14. package/dist/core/auth.js.map +1 -1
  15. package/dist/core/entities/administrator-read-model.d.ts +21 -0
  16. package/dist/core/entities/administrator-read-model.d.ts.map +1 -0
  17. package/dist/core/entities/administrator-read-model.js +3 -0
  18. package/dist/core/entities/administrator-read-model.js.map +1 -0
  19. package/dist/core/errors.d.ts +15 -0
  20. package/dist/core/errors.d.ts.map +1 -1
  21. package/dist/core/errors.js +30 -0
  22. package/dist/core/errors.js.map +1 -1
  23. package/dist/db/admin-grants.d.ts +34 -1
  24. package/dist/db/admin-grants.d.ts.map +1 -1
  25. package/dist/db/admin-grants.js +177 -5
  26. package/dist/db/admin-grants.js.map +1 -1
  27. package/dist/db/admin-users.d.ts +21 -0
  28. package/dist/db/admin-users.d.ts.map +1 -0
  29. package/dist/db/admin-users.js +32 -0
  30. package/dist/db/admin-users.js.map +1 -0
  31. package/dist/db/db.d.ts +256 -0
  32. package/dist/db/db.d.ts.map +1 -1
  33. package/dist/db/db.js +2 -0
  34. package/dist/db/db.js.map +1 -1
  35. package/dist/db/schema/admin-command-results.d.ts +142 -0
  36. package/dist/db/schema/admin-command-results.d.ts.map +1 -0
  37. package/dist/db/schema/admin-command-results.js +21 -0
  38. package/dist/db/schema/admin-command-results.js.map +1 -0
  39. package/dist/index.d.ts +2 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +12 -3
  42. package/dist/index.js.map +1 -1
  43. package/dist/metrics/instance.d.ts +1 -1
  44. package/dist/metrics/instance.d.ts.map +1 -1
  45. package/dist/metrics/instance.js.map +1 -1
  46. package/dist/presentation/routes/administration.d.ts +4 -0
  47. package/dist/presentation/routes/administration.d.ts.map +1 -0
  48. package/dist/presentation/routes/administration.js +267 -0
  49. package/dist/presentation/routes/administration.js.map +1 -0
  50. package/dist/presentation/routes/rate-limit.d.ts +1 -0
  51. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  52. package/dist/presentation/routes/rate-limit.js +26 -1
  53. package/dist/presentation/routes/rate-limit.js.map +1 -1
  54. package/dist/tsconfig.test.tsbuildinfo +1 -1
  55. package/drizzle/0002_burly_whizzer.sql +12 -0
  56. package/drizzle/meta/0002_snapshot.json +791 -0
  57. package/drizzle/meta/_journal.json +7 -0
  58. package/package.json +10 -9
  59. package/src/config.ts +4 -0
  60. package/src/core/administration.ts +213 -0
  61. package/src/core/auth-default-signup-policy.test.ts +65 -0
  62. package/src/core/auth.test.ts +5 -0
  63. package/src/core/auth.ts +2 -3
  64. package/src/core/entities/administrator-read-model.ts +23 -0
  65. package/src/core/errors.ts +35 -0
  66. package/src/db/admin-grants.test.ts +1 -7
  67. package/src/db/admin-grants.ts +310 -5
  68. package/src/db/admin-users.ts +58 -0
  69. package/src/db/db.ts +2 -0
  70. package/src/db/schema/admin-command-results.ts +41 -0
  71. package/src/index.test.ts +6 -2
  72. package/src/index.ts +23 -2
  73. package/src/metrics/instance.ts +1 -1
  74. package/src/presentation/auth/refresh-cookie.test.ts +4 -0
  75. package/src/presentation/e2eRoutes/index.test.ts +1 -0
  76. package/src/presentation/routes/administration.test.ts +537 -0
  77. package/src/presentation/routes/administration.ts +276 -0
  78. package/src/presentation/routes/index.test.ts +1 -0
  79. package/src/presentation/routes/rate-limit.ts +26 -2
  80. package/test/globalSetup.ts +6 -2
  81. package/test/routes.ts +10 -1
  82. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,537 @@
1
+ import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
2
+ import {sql} from 'drizzle-orm';
3
+ import type {FastifyInstance} from 'fastify';
4
+ import {db} from '#db/db.js';
5
+ import {authOutbox} from '#db/schema/outbox.js';
6
+ import {createAuthTestApp, createVerifiedSession, resetCapturedMail} from '#test/routes.js';
7
+
8
+ const BOOTSTRAP_TOKEN = 'test-bootstrap-token';
9
+
10
+ async function resetAdministrationState(): Promise<void> {
11
+ await db().execute(
12
+ sql`TRUNCATE auth_admin_command_results, auth_admin_grants, auth_outbox, auth_rate_limits CASCADE`,
13
+ );
14
+ }
15
+
16
+ function authHeaders(token: string, idempotencyKey: string) {
17
+ return {
18
+ authorization: `Bearer ${token}`,
19
+ 'idempotency-key': idempotencyKey,
20
+ };
21
+ }
22
+
23
+ describe('Auth administration routes', () => {
24
+ let app: FastifyInstance;
25
+
26
+ beforeAll(async () => {
27
+ app = await createAuthTestApp();
28
+ });
29
+
30
+ beforeEach(async () => {
31
+ resetCapturedMail();
32
+ await resetAdministrationState();
33
+ });
34
+
35
+ afterAll(async () => {
36
+ await resetAdministrationState();
37
+ await app.close();
38
+ });
39
+
40
+ test('requires an authenticated session for bootstrap', async () => {
41
+ const response = await app.inject({
42
+ method: 'POST',
43
+ url: '/admin/auth/admin-grants/bootstrap',
44
+ headers: {'idempotency-key': 'anonymous-bootstrap'},
45
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
46
+ });
47
+
48
+ expect(response.statusCode).toBe(401);
49
+ });
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
+
60
+ test('rejects an invalid bootstrap token without writing a grant or event', async () => {
61
+ const account = await createVerifiedSession('admin-bootstrap-invalid');
62
+
63
+ const response = await app.inject({
64
+ method: 'POST',
65
+ url: '/admin/auth/admin-grants/bootstrap',
66
+ headers: authHeaders(account.token, 'invalid-bootstrap'),
67
+ payload: {bootstrap_token: 'wrong-token'},
68
+ });
69
+
70
+ expect(response.statusCode).toBe(403);
71
+ expect(response.json().code).toBe('bootstrap-token-invalid');
72
+ await expect(db().select().from(authOutbox)).resolves.toHaveLength(0);
73
+ });
74
+
75
+ test('rate-limits repeated bootstrap attempts by source IP', async () => {
76
+ const account = await createVerifiedSession('admin-bootstrap-rate-limit');
77
+
78
+ for (let attempt = 0; attempt < 5; attempt += 1) {
79
+ const response = await app.inject({
80
+ method: 'POST',
81
+ url: '/admin/auth/admin-grants/bootstrap',
82
+ headers: authHeaders(account.token, `bootstrap-rate-limit-${attempt}`),
83
+ payload: {bootstrap_token: 'wrong-token'},
84
+ });
85
+ expect(response.statusCode).toBe(403);
86
+ }
87
+
88
+ const blocked = await app.inject({
89
+ method: 'POST',
90
+ url: '/admin/auth/admin-grants/bootstrap',
91
+ headers: authHeaders(account.token, 'bootstrap-rate-limit-blocked'),
92
+ payload: {bootstrap_token: 'wrong-token'},
93
+ });
94
+ expect(blocked.statusCode).toBe(429);
95
+ expect(blocked.json().code).toBe('rate-limited');
96
+ });
97
+
98
+ test('bootstraps exactly one owner and permanently closes bootstrap', async () => {
99
+ const first = await createVerifiedSession('admin-bootstrap-first');
100
+ const second = await createVerifiedSession('admin-bootstrap-second');
101
+
102
+ const response = await app.inject({
103
+ method: 'POST',
104
+ url: '/admin/auth/admin-grants/bootstrap',
105
+ headers: authHeaders(first.token, 'bootstrap-first'),
106
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
107
+ });
108
+
109
+ expect(response.statusCode).toBe(201);
110
+ expect(response.json()).toMatchObject({user_id: first.userId, role: 'admin-owner'});
111
+
112
+ const repeated = await app.inject({
113
+ method: 'POST',
114
+ url: '/admin/auth/admin-grants/bootstrap',
115
+ headers: authHeaders(first.token, 'bootstrap-first'),
116
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
117
+ });
118
+ expect(repeated.statusCode).toBe(201);
119
+ expect(repeated.json().id).toBe(response.json().id);
120
+
121
+ const secondAttempt = await app.inject({
122
+ method: 'POST',
123
+ url: '/admin/auth/admin-grants/bootstrap',
124
+ headers: authHeaders(second.token, 'bootstrap-second'),
125
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
126
+ });
127
+ expect(secondAttempt.statusCode).toBe(409);
128
+ expect(secondAttempt.json().code).toBe('bootstrap-closed');
129
+
130
+ const events = await db().select().from(authOutbox);
131
+ expect(events).toHaveLength(1);
132
+ expect(events[0]?.eventType).toBe(ADMINISTRATION_ACTION_PERFORMED);
133
+ expect(events[0]?.payload).toMatchObject({
134
+ actorId: first.userId,
135
+ actorRole: 'admin-owner',
136
+ requiredRole: 'admin-owner',
137
+ command: 'auth.admin_grant.bootstrap',
138
+ result: 'succeeded',
139
+ });
140
+ expect(JSON.stringify(events[0]?.payload)).not.toContain(BOOTSTRAP_TOKEN);
141
+ });
142
+
143
+ test('lets an owner list, grant, and revoke roles with idempotent audited mutations', async () => {
144
+ const owner = await createVerifiedSession('admin-grant-owner');
145
+ const target = await createVerifiedSession('admin-grant-target');
146
+
147
+ const bootstrap = await app.inject({
148
+ method: 'POST',
149
+ url: '/admin/auth/admin-grants/bootstrap',
150
+ headers: authHeaders(owner.token, 'grant-bootstrap'),
151
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
152
+ });
153
+ expect(bootstrap.statusCode).toBe(201);
154
+
155
+ const grant = await app.inject({
156
+ method: 'POST',
157
+ url: '/admin/auth/admin-grants',
158
+ headers: authHeaders(owner.token, 'grant-observer'),
159
+ payload: {
160
+ user_id: target.userId,
161
+ role: 'admin-observer',
162
+ reason: 'Support investigation',
163
+ },
164
+ });
165
+ expect(grant.statusCode).toBe(201);
166
+
167
+ const repeatedGrant = await app.inject({
168
+ method: 'POST',
169
+ url: '/admin/auth/admin-grants',
170
+ headers: authHeaders(owner.token, 'grant-observer'),
171
+ payload: {
172
+ user_id: target.userId,
173
+ role: 'admin-observer',
174
+ reason: 'Support investigation',
175
+ },
176
+ });
177
+ expect(repeatedGrant.statusCode).toBe(201);
178
+ expect(repeatedGrant.json().id).toBe(grant.json().id);
179
+
180
+ const grants = await app.inject({
181
+ method: 'GET',
182
+ url: '/admin/auth/admin-grants',
183
+ headers: {authorization: `Bearer ${owner.token}`},
184
+ });
185
+ expect(grants.statusCode).toBe(200);
186
+ expect(grants.json().grants).toEqual(
187
+ expect.arrayContaining([
188
+ expect.objectContaining({user: expect.objectContaining({id: target.userId})}),
189
+ ]),
190
+ );
191
+
192
+ const revoked = await app.inject({
193
+ method: 'DELETE',
194
+ url: `/admin/auth/admin-grants/${grant.json().id}`,
195
+ headers: authHeaders(owner.token, 'revoke-observer'),
196
+ payload: {reason: 'Support investigation complete'},
197
+ });
198
+ expect(revoked.statusCode).toBe(200);
199
+ expect(revoked.json()).toMatchObject({id: grant.json().id, revoked_at: expect.any(String)});
200
+
201
+ const repeatedRevoke = await app.inject({
202
+ method: 'DELETE',
203
+ url: `/admin/auth/admin-grants/${grant.json().id}`,
204
+ headers: authHeaders(owner.token, 'revoke-observer'),
205
+ payload: {reason: 'Support investigation complete'},
206
+ });
207
+ expect(repeatedRevoke.statusCode).toBe(200);
208
+ expect(repeatedRevoke.json().revoked_at).toBe(revoked.json().revoked_at);
209
+
210
+ const events = await db().select().from(authOutbox);
211
+ expect(events).toHaveLength(3);
212
+ expect(events.map((event) => event.eventType)).toEqual(
213
+ Array(3).fill(ADMINISTRATION_ACTION_PERFORMED),
214
+ );
215
+ });
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
+
490
+ test('protects the final active owner and rejects idempotency-key reuse', async () => {
491
+ const owner = await createVerifiedSession('admin-final-owner');
492
+ const target = await createVerifiedSession('admin-final-target');
493
+
494
+ await app.inject({
495
+ method: 'POST',
496
+ url: '/admin/auth/admin-grants/bootstrap',
497
+ headers: authHeaders(owner.token, 'final-bootstrap'),
498
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
499
+ });
500
+
501
+ const firstGrant = await app.inject({
502
+ method: 'POST',
503
+ url: '/admin/auth/admin-grants',
504
+ headers: authHeaders(owner.token, 'role-key'),
505
+ payload: {user_id: target.userId, role: 'admin-observer', reason: 'Initial review'},
506
+ });
507
+ expect(firstGrant.statusCode).toBe(201);
508
+
509
+ const reusedKey = await app.inject({
510
+ method: 'POST',
511
+ url: '/admin/auth/admin-grants',
512
+ headers: authHeaders(owner.token, 'role-key'),
513
+ payload: {user_id: target.userId, role: 'admin-operator', reason: 'Different command'},
514
+ });
515
+ expect(reusedKey.statusCode).toBe(409);
516
+ expect(reusedKey.json().code).toBe('idempotency-key-reused');
517
+
518
+ const finalOwnerRevoke = await app.inject({
519
+ method: 'DELETE',
520
+ url: `/admin/auth/admin-grants/${
521
+ (
522
+ await app.inject({
523
+ method: 'GET',
524
+ url: '/admin/auth/admin-grants',
525
+ headers: {authorization: `Bearer ${owner.token}`},
526
+ })
527
+ )
528
+ .json()
529
+ .grants.find((grant: {user: {id: string}}) => grant.user.id === owner.userId).grant_id
530
+ }`,
531
+ headers: authHeaders(owner.token, 'revoke-final-owner'),
532
+ payload: {reason: 'Attempt to remove final owner'},
533
+ });
534
+ expect(finalOwnerRevoke.statusCode).toBe(409);
535
+ expect(finalOwnerRevoke.json().code).toBe('last-owner');
536
+ });
537
+ });