@shipfox/api-auth 9.1.0 → 9.3.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 (136) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +44 -2
  4. package/dist/config.d.ts +5 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +26 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/core/admin-role-model.d.ts +5 -0
  9. package/dist/core/admin-role-model.d.ts.map +1 -0
  10. package/dist/core/admin-role-model.js +18 -0
  11. package/dist/core/admin-role-model.js.map +1 -0
  12. package/dist/core/admin-role.d.ts +13 -0
  13. package/dist/core/admin-role.d.ts.map +1 -0
  14. package/dist/core/admin-role.js +21 -0
  15. package/dist/core/admin-role.js.map +1 -0
  16. package/dist/core/administration.d.ts +27 -0
  17. package/dist/core/administration.d.ts.map +1 -0
  18. package/dist/core/administration.js +122 -0
  19. package/dist/core/administration.js.map +1 -0
  20. package/dist/core/auth.d.ts +9 -0
  21. package/dist/core/auth.d.ts.map +1 -1
  22. package/dist/core/auth.js +68 -20
  23. package/dist/core/auth.js.map +1 -1
  24. package/dist/core/entities/admin-grant.d.ts +10 -0
  25. package/dist/core/entities/admin-grant.d.ts.map +1 -0
  26. package/dist/core/entities/admin-grant.js +3 -0
  27. package/dist/core/entities/admin-grant.js.map +1 -0
  28. package/dist/core/errors.d.ts +25 -0
  29. package/dist/core/errors.d.ts.map +1 -1
  30. package/dist/core/errors.js +49 -0
  31. package/dist/core/errors.js.map +1 -1
  32. package/dist/core/ports.d.ts +13 -0
  33. package/dist/core/ports.d.ts.map +1 -0
  34. package/dist/core/ports.js +3 -0
  35. package/dist/core/ports.js.map +1 -0
  36. package/dist/core/signup-policy.d.ts +10 -0
  37. package/dist/core/signup-policy.d.ts.map +1 -0
  38. package/dist/core/signup-policy.js +39 -0
  39. package/dist/core/signup-policy.js.map +1 -0
  40. package/dist/db/admin-grants.d.ts +33 -0
  41. package/dist/db/admin-grants.d.ts.map +1 -0
  42. package/dist/db/admin-grants.js +193 -0
  43. package/dist/db/admin-grants.js.map +1 -0
  44. package/dist/db/db.d.ts +474 -0
  45. package/dist/db/db.d.ts.map +1 -1
  46. package/dist/db/db.js +4 -0
  47. package/dist/db/db.js.map +1 -1
  48. package/dist/db/schema/admin-command-results.d.ts +142 -0
  49. package/dist/db/schema/admin-command-results.d.ts.map +1 -0
  50. package/dist/db/schema/admin-command-results.js +21 -0
  51. package/dist/db/schema/admin-command-results.js.map +1 -0
  52. package/dist/db/schema/admin-grants.d.ts +115 -0
  53. package/dist/db/schema/admin-grants.d.ts.map +1 -0
  54. package/dist/db/schema/admin-grants.js +42 -0
  55. package/dist/db/schema/admin-grants.js.map +1 -0
  56. package/dist/index.d.ts +12 -4
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +15 -4
  59. package/dist/index.js.map +1 -1
  60. package/dist/metrics/instance.d.ts +1 -1
  61. package/dist/metrics/instance.d.ts.map +1 -1
  62. package/dist/metrics/instance.js.map +1 -1
  63. package/dist/presentation/dto/user.d.ts +2 -1
  64. package/dist/presentation/dto/user.d.ts.map +1 -1
  65. package/dist/presentation/dto/user.js +2 -1
  66. package/dist/presentation/dto/user.js.map +1 -1
  67. package/dist/presentation/inter-module.d.ts.map +1 -1
  68. package/dist/presentation/inter-module.js +26 -2
  69. package/dist/presentation/inter-module.js.map +1 -1
  70. package/dist/presentation/routes/administration.d.ts +3 -0
  71. package/dist/presentation/routes/administration.d.ts.map +1 -0
  72. package/dist/presentation/routes/administration.js +186 -0
  73. package/dist/presentation/routes/administration.js.map +1 -0
  74. package/dist/presentation/routes/index.d.ts +2 -1
  75. package/dist/presentation/routes/index.d.ts.map +1 -1
  76. package/dist/presentation/routes/index.js +2 -2
  77. package/dist/presentation/routes/index.js.map +1 -1
  78. package/dist/presentation/routes/rate-limit.d.ts +1 -0
  79. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  80. package/dist/presentation/routes/rate-limit.js +20 -1
  81. package/dist/presentation/routes/rate-limit.js.map +1 -1
  82. package/dist/presentation/routes/registration/signup.d.ts +2 -1
  83. package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
  84. package/dist/presentation/routes/registration/signup.js +14 -3
  85. package/dist/presentation/routes/registration/signup.js.map +1 -1
  86. package/dist/presentation/routes/session/me.d.ts.map +1 -1
  87. package/dist/presentation/routes/session/me.js +5 -1
  88. package/dist/presentation/routes/session/me.js.map +1 -1
  89. package/dist/tsconfig.test.tsbuildinfo +1 -1
  90. package/drizzle/0001_brainy_shriek.sql +14 -0
  91. package/drizzle/0002_burly_whizzer.sql +12 -0
  92. package/drizzle/meta/0001_snapshot.json +702 -0
  93. package/drizzle/meta/0002_snapshot.json +791 -0
  94. package/drizzle/meta/_journal.json +14 -0
  95. package/package.json +10 -9
  96. package/src/config.test.ts +39 -0
  97. package/src/config.ts +34 -0
  98. package/src/core/admin-role-model.ts +25 -0
  99. package/src/core/admin-role.test.ts +39 -0
  100. package/src/core/admin-role.ts +25 -0
  101. package/src/core/administration.ts +172 -0
  102. package/src/core/auth-default-signup-policy.test.ts +65 -0
  103. package/src/core/auth.test.ts +115 -0
  104. package/src/core/auth.ts +77 -21
  105. package/src/core/entities/admin-grant.ts +10 -0
  106. package/src/core/errors.ts +59 -0
  107. package/src/core/jwt.test.ts +1 -0
  108. package/src/core/ports.ts +7 -0
  109. package/src/core/signup-policy.test.ts +78 -0
  110. package/src/core/signup-policy.ts +42 -0
  111. package/src/db/admin-grants.test.ts +57 -0
  112. package/src/db/admin-grants.ts +340 -0
  113. package/src/db/db.ts +4 -0
  114. package/src/db/schema/admin-command-results.ts +41 -0
  115. package/src/db/schema/admin-grants.ts +49 -0
  116. package/src/index.test.ts +58 -1
  117. package/src/index.ts +47 -6
  118. package/src/metrics/instance.ts +1 -1
  119. package/src/presentation/auth/refresh-cookie.test.ts +4 -0
  120. package/src/presentation/dto/user.ts +7 -2
  121. package/src/presentation/e2eRoutes/index.test.ts +1 -0
  122. package/src/presentation/inter-module.test.ts +47 -0
  123. package/src/presentation/inter-module.ts +22 -1
  124. package/src/presentation/routes/administration.test.ts +253 -0
  125. package/src/presentation/routes/administration.ts +195 -0
  126. package/src/presentation/routes/index.test.ts +1 -0
  127. package/src/presentation/routes/index.ts +3 -1
  128. package/src/presentation/routes/rate-limit.ts +23 -2
  129. package/src/presentation/routes/registration/signup.test.ts +26 -0
  130. package/src/presentation/routes/registration/signup.ts +22 -2
  131. package/src/presentation/routes/session/login.test.ts +1 -0
  132. package/src/presentation/routes/session/me.test.ts +18 -2
  133. package/src/presentation/routes/session/me.ts +5 -1
  134. package/test/globalSetup.ts +6 -2
  135. package/test/routes.ts +7 -1
  136. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,4 +1,4 @@
1
- import type {LoginResponseDto, UserDto} from '@shipfox/api-auth-dto';
1
+ import type {AdminRole, LoginResponseDto, UserDto} from '@shipfox/api-auth-dto';
2
2
  import type {User} from '#core/entities/user.js';
3
3
 
4
4
  export function toUserDto(user: User): UserDto {
@@ -13,9 +13,14 @@ export function toUserDto(user: User): UserDto {
13
13
  };
14
14
  }
15
15
 
16
- export function toAuthSessionDto(result: {token: string; user: User}): LoginResponseDto {
16
+ export function toAuthSessionDto(result: {
17
+ token: string;
18
+ user: User;
19
+ adminRole?: AdminRole | null;
20
+ }): LoginResponseDto {
17
21
  return {
18
22
  token: result.token,
19
23
  user: toUserDto(result.user),
24
+ admin_role: result.adminRole ?? null,
20
25
  };
21
26
  }
@@ -14,6 +14,7 @@ describe('auth E2E routes', () => {
14
14
  preflightInvitationAcceptance: vi.fn(),
15
15
  acceptInvitation: vi.fn(),
16
16
  requireActiveMembership: vi.fn(),
17
+ getWorkspaceOperatingState: vi.fn(),
17
18
  }),
18
19
  ],
19
20
  swagger: false,
@@ -0,0 +1,47 @@
1
+ import {authInterModuleContract} from '@shipfox/api-auth-dto/inter-module';
2
+ import {isInterModuleKnownError} from '@shipfox/inter-module';
3
+ import {createInMemoryInterModuleTransport} from '@shipfox/node-module/inter-module';
4
+ import {createAdminGrant} from '#db/admin-grants.js';
5
+ import {userFactory} from '#test/index.js';
6
+ import {createAuthInterModulePresentation} from './inter-module.js';
7
+
8
+ function createClient() {
9
+ const transport = createInMemoryInterModuleTransport();
10
+ const client = transport.createClient(authInterModuleContract);
11
+ transport.register(createAuthInterModulePresentation());
12
+ transport.seal();
13
+ return client;
14
+ }
15
+
16
+ describe('Auth inter-module administration role presentation', () => {
17
+ test('returns the current role from Auth storage', async () => {
18
+ const client = createClient();
19
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
20
+ await createAdminGrant({userId: user.id, role: 'admin-owner'});
21
+
22
+ await expect(client.getCurrentAdminRole({userId: user.id})).resolves.toEqual({
23
+ role: 'admin-owner',
24
+ });
25
+ await expect(
26
+ client.requireAdminRole({userId: user.id, minimumRole: 'admin-operator'}),
27
+ ).resolves.toEqual({role: 'admin-owner'});
28
+ });
29
+
30
+ test('maps an insufficient current role to the declared known error', async () => {
31
+ const client = createClient();
32
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
33
+ await createAdminGrant({userId: user.id, role: 'admin-observer'});
34
+
35
+ const error = await client
36
+ .requireAdminRole({userId: user.id, minimumRole: 'admin-owner'})
37
+ .catch((caught: unknown) => caught);
38
+
39
+ expect(isInterModuleKnownError(authInterModuleContract.methods.requireAdminRole, error)).toBe(
40
+ true,
41
+ );
42
+ if (isInterModuleKnownError(authInterModuleContract.methods.requireAdminRole, error)) {
43
+ expect(error.code).toBe('admin-role-required');
44
+ expect(error.details).toEqual({requiredRole: 'admin-owner'});
45
+ }
46
+ });
47
+ });
@@ -1,5 +1,11 @@
1
1
  import {authInterModuleContract} from '@shipfox/api-auth-dto/inter-module';
2
- import {defineInterModulePresentation, type InterModulePresentation} from '@shipfox/inter-module';
2
+ import {
3
+ createInterModuleKnownError,
4
+ defineInterModulePresentation,
5
+ type InterModulePresentation,
6
+ } from '@shipfox/inter-module';
7
+ import {getCurrentAdminRole, requireAdminRole} from '#core/admin-role.js';
8
+ import {AdminRoleRequiredError} from '#core/errors.js';
3
9
  import {issueJobLeaseToken} from '#core/job-lease-token.js';
4
10
  import {issueRunnerSessionToken} from '#core/runner-session-token.js';
5
11
 
@@ -9,5 +15,20 @@ export function createAuthInterModulePresentation(): InterModulePresentation<
9
15
  return defineInterModulePresentation(authInterModuleContract, {
10
16
  mintRunnerSessionToken: async (claims) => ({token: await issueRunnerSessionToken(claims)}),
11
17
  mintJobLeaseToken: async (claims) => ({token: await issueJobLeaseToken(claims)}),
18
+ getCurrentAdminRole: async ({userId}) => ({role: await getCurrentAdminRole({userId})}),
19
+ requireAdminRole: async ({userId, minimumRole}) => {
20
+ try {
21
+ return {role: await requireAdminRole({userId, minimumRole})};
22
+ } catch (error) {
23
+ if (error instanceof AdminRoleRequiredError) {
24
+ throw createInterModuleKnownError(
25
+ authInterModuleContract.methods.requireAdminRole,
26
+ 'admin-role-required',
27
+ {requiredRole: error.minimumRole},
28
+ );
29
+ }
30
+ throw error;
31
+ }
32
+ },
12
33
  });
13
34
  }
@@ -0,0 +1,253 @@
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/v1/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('rejects an invalid bootstrap token without writing a grant or event', async () => {
52
+ const account = await createVerifiedSession('admin-bootstrap-invalid');
53
+
54
+ const response = await app.inject({
55
+ method: 'POST',
56
+ url: '/admin/v1/auth/admin-grants/bootstrap',
57
+ headers: authHeaders(account.token, 'invalid-bootstrap'),
58
+ payload: {bootstrap_token: 'wrong-token'},
59
+ });
60
+
61
+ expect(response.statusCode).toBe(403);
62
+ expect(response.json().code).toBe('bootstrap-token-invalid');
63
+ await expect(db().select().from(authOutbox)).resolves.toHaveLength(0);
64
+ });
65
+
66
+ test('rate-limits repeated bootstrap attempts by source IP', async () => {
67
+ const account = await createVerifiedSession('admin-bootstrap-rate-limit');
68
+
69
+ for (let attempt = 0; attempt < 5; attempt += 1) {
70
+ const response = await app.inject({
71
+ method: 'POST',
72
+ url: '/admin/v1/auth/admin-grants/bootstrap',
73
+ headers: authHeaders(account.token, `bootstrap-rate-limit-${attempt}`),
74
+ payload: {bootstrap_token: 'wrong-token'},
75
+ });
76
+ expect(response.statusCode).toBe(403);
77
+ }
78
+
79
+ const blocked = await app.inject({
80
+ method: 'POST',
81
+ url: '/admin/v1/auth/admin-grants/bootstrap',
82
+ headers: authHeaders(account.token, 'bootstrap-rate-limit-blocked'),
83
+ payload: {bootstrap_token: 'wrong-token'},
84
+ });
85
+ expect(blocked.statusCode).toBe(429);
86
+ expect(blocked.json().code).toBe('rate-limited');
87
+ });
88
+
89
+ test('bootstraps exactly one owner and permanently closes bootstrap', async () => {
90
+ const first = await createVerifiedSession('admin-bootstrap-first');
91
+ const second = await createVerifiedSession('admin-bootstrap-second');
92
+
93
+ const response = await app.inject({
94
+ method: 'POST',
95
+ url: '/admin/v1/auth/admin-grants/bootstrap',
96
+ headers: authHeaders(first.token, 'bootstrap-first'),
97
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
98
+ });
99
+
100
+ expect(response.statusCode).toBe(201);
101
+ expect(response.json()).toMatchObject({user_id: first.userId, role: 'admin-owner'});
102
+
103
+ const repeated = await app.inject({
104
+ method: 'POST',
105
+ url: '/admin/v1/auth/admin-grants/bootstrap',
106
+ headers: authHeaders(first.token, 'bootstrap-first'),
107
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
108
+ });
109
+ expect(repeated.statusCode).toBe(201);
110
+ expect(repeated.json().id).toBe(response.json().id);
111
+
112
+ const secondAttempt = await app.inject({
113
+ method: 'POST',
114
+ url: '/admin/v1/auth/admin-grants/bootstrap',
115
+ headers: authHeaders(second.token, 'bootstrap-second'),
116
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
117
+ });
118
+ expect(secondAttempt.statusCode).toBe(409);
119
+ expect(secondAttempt.json().code).toBe('bootstrap-closed');
120
+
121
+ const events = await db().select().from(authOutbox);
122
+ expect(events).toHaveLength(1);
123
+ expect(events[0]?.eventType).toBe(ADMINISTRATION_ACTION_PERFORMED);
124
+ expect(events[0]?.payload).toMatchObject({
125
+ actorId: first.userId,
126
+ actorRole: 'admin-owner',
127
+ requiredRole: 'admin-owner',
128
+ command: 'auth.admin_grant.bootstrap',
129
+ result: 'succeeded',
130
+ });
131
+ expect(JSON.stringify(events[0]?.payload)).not.toContain(BOOTSTRAP_TOKEN);
132
+ });
133
+
134
+ test('lets an owner list, grant, and revoke roles with idempotent audited mutations', async () => {
135
+ const owner = await createVerifiedSession('admin-grant-owner');
136
+ const target = await createVerifiedSession('admin-grant-target');
137
+
138
+ const bootstrap = await app.inject({
139
+ method: 'POST',
140
+ url: '/admin/v1/auth/admin-grants/bootstrap',
141
+ headers: authHeaders(owner.token, 'grant-bootstrap'),
142
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
143
+ });
144
+ expect(bootstrap.statusCode).toBe(201);
145
+
146
+ const grant = await app.inject({
147
+ method: 'POST',
148
+ url: '/admin/v1/auth/admin-grants',
149
+ headers: authHeaders(owner.token, 'grant-observer'),
150
+ payload: {
151
+ user_id: target.userId,
152
+ role: 'admin-observer',
153
+ reason: 'Support investigation',
154
+ },
155
+ });
156
+ expect(grant.statusCode).toBe(201);
157
+
158
+ const repeatedGrant = await app.inject({
159
+ method: 'POST',
160
+ url: '/admin/v1/auth/admin-grants',
161
+ headers: authHeaders(owner.token, 'grant-observer'),
162
+ payload: {
163
+ user_id: target.userId,
164
+ role: 'admin-observer',
165
+ reason: 'Support investigation',
166
+ },
167
+ });
168
+ expect(repeatedGrant.statusCode).toBe(201);
169
+ expect(repeatedGrant.json().id).toBe(grant.json().id);
170
+
171
+ const grants = await app.inject({
172
+ method: 'GET',
173
+ url: '/admin/v1/auth/admin-grants',
174
+ headers: {authorization: `Bearer ${owner.token}`},
175
+ });
176
+ expect(grants.statusCode).toBe(200);
177
+ expect(grants.json().grants).toEqual(
178
+ expect.arrayContaining([expect.objectContaining({user_id: target.userId})]),
179
+ );
180
+
181
+ const revoked = await app.inject({
182
+ method: 'DELETE',
183
+ url: `/admin/v1/auth/admin-grants/${grant.json().id}`,
184
+ headers: authHeaders(owner.token, 'revoke-observer'),
185
+ payload: {reason: 'Support investigation complete'},
186
+ });
187
+ expect(revoked.statusCode).toBe(200);
188
+ expect(revoked.json()).toMatchObject({id: grant.json().id, revoked_at: expect.any(String)});
189
+
190
+ const repeatedRevoke = await app.inject({
191
+ method: 'DELETE',
192
+ url: `/admin/v1/auth/admin-grants/${grant.json().id}`,
193
+ headers: authHeaders(owner.token, 'revoke-observer'),
194
+ payload: {reason: 'Support investigation complete'},
195
+ });
196
+ expect(repeatedRevoke.statusCode).toBe(200);
197
+ expect(repeatedRevoke.json().revoked_at).toBe(revoked.json().revoked_at);
198
+
199
+ const events = await db().select().from(authOutbox);
200
+ expect(events).toHaveLength(3);
201
+ expect(events.map((event) => event.eventType)).toEqual(
202
+ Array(3).fill(ADMINISTRATION_ACTION_PERFORMED),
203
+ );
204
+ });
205
+
206
+ test('protects the final active owner and rejects idempotency-key reuse', async () => {
207
+ const owner = await createVerifiedSession('admin-final-owner');
208
+ const target = await createVerifiedSession('admin-final-target');
209
+
210
+ await app.inject({
211
+ method: 'POST',
212
+ url: '/admin/v1/auth/admin-grants/bootstrap',
213
+ headers: authHeaders(owner.token, 'final-bootstrap'),
214
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
215
+ });
216
+
217
+ const firstGrant = await app.inject({
218
+ method: 'POST',
219
+ url: '/admin/v1/auth/admin-grants',
220
+ headers: authHeaders(owner.token, 'role-key'),
221
+ payload: {user_id: target.userId, role: 'admin-observer', reason: 'Initial review'},
222
+ });
223
+ expect(firstGrant.statusCode).toBe(201);
224
+
225
+ const reusedKey = await app.inject({
226
+ method: 'POST',
227
+ url: '/admin/v1/auth/admin-grants',
228
+ headers: authHeaders(owner.token, 'role-key'),
229
+ payload: {user_id: target.userId, role: 'admin-operator', reason: 'Different command'},
230
+ });
231
+ expect(reusedKey.statusCode).toBe(409);
232
+ expect(reusedKey.json().code).toBe('idempotency-key-reused');
233
+
234
+ const finalOwnerRevoke = await app.inject({
235
+ method: 'DELETE',
236
+ url: `/admin/v1/auth/admin-grants/${
237
+ (
238
+ await app.inject({
239
+ method: 'GET',
240
+ url: '/admin/v1/auth/admin-grants',
241
+ headers: {authorization: `Bearer ${owner.token}`},
242
+ })
243
+ )
244
+ .json()
245
+ .grants.find((grant: {user_id: string}) => grant.user_id === owner.userId).id
246
+ }`,
247
+ headers: authHeaders(owner.token, 'revoke-final-owner'),
248
+ payload: {reason: 'Attempt to remove final owner'},
249
+ });
250
+ expect(finalOwnerRevoke.statusCode).toBe(409);
251
+ expect(finalOwnerRevoke.json().code).toBe('last-owner');
252
+ });
253
+ });
@@ -0,0 +1,195 @@
1
+ import {AUTH_USER} from '@shipfox/api-auth-context';
2
+ import {
3
+ bootstrapAdminOwnerBodySchema,
4
+ bootstrapAdminOwnerResponseSchema,
5
+ grantAdminRoleBodySchema,
6
+ grantAdminRoleResponseSchema,
7
+ listAdminGrantsResponseSchema,
8
+ revokeAdminGrantBodySchema,
9
+ revokeAdminGrantResponseSchema,
10
+ } from '@shipfox/api-auth-dto';
11
+ import {ClientError, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
12
+ import type {FastifyRequest} from 'fastify';
13
+ import {z} from 'zod';
14
+ import {
15
+ bootstrapFirstAdminOwner,
16
+ grantAdministratorRole,
17
+ listAdministratorGrants,
18
+ revokeAdministratorGrant,
19
+ } from '#core/administration.js';
20
+ import type {AdminGrant} from '#core/entities/admin-grant.js';
21
+ import {
22
+ AdminBootstrapClosedError,
23
+ AdminGrantAlreadyExistsError,
24
+ AdminGrantNotFoundError,
25
+ AdminIdempotencyKeyReuseError,
26
+ AdminRoleRequiredError,
27
+ InvalidAdminBootstrapTokenError,
28
+ LastAdminOwnerError,
29
+ UserNotFoundError,
30
+ } from '#core/errors.js';
31
+ import {getClientContext} from '#presentation/auth/jwt-auth.js';
32
+ import {createAuthIpRateLimitPreHandler} from './rate-limit.js';
33
+
34
+ const idempotencyKeyMaxLength = 256;
35
+
36
+ function requireActorId(request: FastifyRequest): string {
37
+ const client = getClientContext(request);
38
+ if (!client) {
39
+ throw new ClientError('Authentication required', 'unauthorized', {status: 401});
40
+ }
41
+ return client.userId;
42
+ }
43
+
44
+ function requireIdempotencyKey(request: FastifyRequest): string {
45
+ const value = request.headers['idempotency-key'];
46
+ const key = Array.isArray(value) ? value[0] : value;
47
+ if (!key || key.trim().length === 0 || key.length > idempotencyKeyMaxLength) {
48
+ throw new ClientError('Idempotency-Key header is required', 'idempotency-key-required', {
49
+ status: 400,
50
+ });
51
+ }
52
+ return key;
53
+ }
54
+
55
+ function toAdminGrantDto(grant: AdminGrant) {
56
+ return {
57
+ id: grant.id,
58
+ user_id: grant.userId,
59
+ role: grant.role,
60
+ revoked_at: grant.revokedAt?.toISOString() ?? null,
61
+ created_at: grant.createdAt.toISOString(),
62
+ updated_at: grant.updatedAt.toISOString(),
63
+ };
64
+ }
65
+
66
+ function translateAdministrationError(error: unknown): never {
67
+ if (error instanceof AdminRoleRequiredError) {
68
+ throw new ClientError('Administrator owner role required', 'forbidden', {
69
+ status: 403,
70
+ details: {required_role: error.minimumRole},
71
+ });
72
+ }
73
+ if (error instanceof InvalidAdminBootstrapTokenError) {
74
+ throw new ClientError('Bootstrap token is invalid', 'bootstrap-token-invalid', {
75
+ status: 403,
76
+ });
77
+ }
78
+ if (error instanceof AdminBootstrapClosedError) {
79
+ throw new ClientError('First administrator owner already exists', 'bootstrap-closed', {
80
+ status: 409,
81
+ });
82
+ }
83
+ if (error instanceof AdminGrantAlreadyExistsError) {
84
+ throw new ClientError('Administrator grant already exists', 'grant-already-exists', {
85
+ status: 409,
86
+ });
87
+ }
88
+ if (error instanceof AdminGrantNotFoundError) {
89
+ throw new ClientError('Administrator grant not found', 'not-found', {status: 404});
90
+ }
91
+ if (error instanceof UserNotFoundError) {
92
+ throw new ClientError('User not found', 'not-found', {status: 404});
93
+ }
94
+ if (error instanceof LastAdminOwnerError) {
95
+ throw new ClientError('Cannot remove the final active administrator owner', 'last-owner', {
96
+ status: 409,
97
+ });
98
+ }
99
+ if (error instanceof AdminIdempotencyKeyReuseError) {
100
+ throw new ClientError(
101
+ 'Idempotency-Key was already used for a different command',
102
+ 'idempotency-key-reused',
103
+ {status: 409},
104
+ );
105
+ }
106
+ throw error;
107
+ }
108
+
109
+ const bootstrapRoute = defineRoute({
110
+ method: 'POST',
111
+ path: '/bootstrap',
112
+ description: 'Claim the first administrator owner role with the deployment bootstrap token.',
113
+ schema: {
114
+ body: bootstrapAdminOwnerBodySchema,
115
+ response: {201: bootstrapAdminOwnerResponseSchema},
116
+ },
117
+ preHandler: createAuthIpRateLimitPreHandler('bootstrap'),
118
+ errorHandler: translateAdministrationError,
119
+ handler: async (request, reply) => {
120
+ const actorId = requireActorId(request);
121
+ const grant = await bootstrapFirstAdminOwner({
122
+ actorId,
123
+ bootstrapToken: request.body.bootstrap_token,
124
+ idempotencyKey: requireIdempotencyKey(request),
125
+ correlationId: request.id,
126
+ });
127
+ reply.code(201);
128
+ return toAdminGrantDto(grant);
129
+ },
130
+ });
131
+
132
+ const listRoute = defineRoute({
133
+ method: 'GET',
134
+ path: '/',
135
+ description: 'List local administrator grants.',
136
+ schema: {response: {200: listAdminGrantsResponseSchema}},
137
+ errorHandler: translateAdministrationError,
138
+ handler: async (request) => ({
139
+ grants: (await listAdministratorGrants({actorId: requireActorId(request)})).map(
140
+ toAdminGrantDto,
141
+ ),
142
+ }),
143
+ });
144
+
145
+ const grantRoute = defineRoute({
146
+ method: 'POST',
147
+ path: '/',
148
+ description: 'Grant a local administrator role to an active user.',
149
+ schema: {
150
+ body: grantAdminRoleBodySchema,
151
+ response: {201: grantAdminRoleResponseSchema},
152
+ },
153
+ errorHandler: translateAdministrationError,
154
+ handler: async (request, reply) => {
155
+ const actorId = requireActorId(request);
156
+ const grant = await grantAdministratorRole({
157
+ actorId,
158
+ userId: request.body.user_id,
159
+ role: request.body.role,
160
+ reason: request.body.reason,
161
+ idempotencyKey: requireIdempotencyKey(request),
162
+ correlationId: request.id,
163
+ });
164
+ reply.code(201);
165
+ return toAdminGrantDto(grant);
166
+ },
167
+ });
168
+
169
+ const revokeRoute = defineRoute({
170
+ method: 'DELETE',
171
+ path: '/:grantId',
172
+ description: 'Revoke a local administrator grant.',
173
+ schema: {
174
+ params: z.object({grantId: z.string().uuid()}),
175
+ body: revokeAdminGrantBodySchema,
176
+ response: {200: revokeAdminGrantResponseSchema},
177
+ },
178
+ errorHandler: translateAdministrationError,
179
+ handler: async (request) => {
180
+ const grant = await revokeAdministratorGrant({
181
+ actorId: requireActorId(request),
182
+ grantId: request.params.grantId,
183
+ reason: request.body.reason,
184
+ idempotencyKey: requireIdempotencyKey(request),
185
+ correlationId: request.id,
186
+ });
187
+ return toAdminGrantDto(grant);
188
+ },
189
+ });
190
+
191
+ export const administrationRoutes: RouteGroup = {
192
+ prefix: '/admin/v1/auth/admin-grants',
193
+ auth: AUTH_USER,
194
+ routes: [bootstrapRoute, listRoute, grantRoute, revokeRoute],
195
+ };
@@ -8,6 +8,7 @@ const workspaces = {
8
8
  preflightInvitationAcceptance: vi.fn(),
9
9
  acceptInvitation: vi.fn(),
10
10
  requireActiveMembership: vi.fn(),
11
+ getWorkspaceOperatingState: vi.fn(),
11
12
  };
12
13
 
13
14
  const disabledPasswordPaths = [
@@ -1,5 +1,6 @@
1
1
  import type {WorkspacesInterModuleClient} from '@shipfox/api-workspaces-dto/inter-module';
2
2
  import type {RouteGroup} from '@shipfox/node-fastify';
3
+ import type {SignupPolicy} from '#core/ports.js';
3
4
  import {authCookiePlugin} from '#presentation/auth/refresh-cookie.js';
4
5
  import {createVerifyEmailConfirmRoute} from './email-verification/verify-email-confirm.js';
5
6
  import {verifyEmailResendRoute} from './email-verification/verify-email-resend.js';
@@ -15,10 +16,11 @@ import {createRefreshRoute} from './session/refresh.js';
15
16
  export function buildAuthRoutes(
16
17
  passwordEnabled: boolean,
17
18
  workspaces: WorkspacesInterModuleClient,
19
+ signupPolicy?: SignupPolicy,
18
20
  ): RouteGroup {
19
21
  const passwordRoutes = passwordEnabled
20
22
  ? [
21
- createSignupRoute(workspaces),
23
+ createSignupRoute(workspaces, signupPolicy),
22
24
  createVerifyEmailConfirmRoute(workspaces),
23
25
  verifyEmailResendRoute,
24
26
  createLoginRoute(workspaces),
@@ -8,7 +8,10 @@ import {
8
8
  checkAuthRateLimit,
9
9
  } from '#core/rate-limit.js';
10
10
 
11
- const policies: Record<AuthRateLimitAction, Record<AuthRateLimitScope, AuthRateLimitPolicy>> = {
11
+ const policies: Record<
12
+ AuthRateLimitAction,
13
+ Partial<Record<AuthRateLimitScope, AuthRateLimitPolicy>>
14
+ > = {
12
15
  login: {
13
16
  ip: {limit: 60, windowSeconds: 5 * 60},
14
17
  email: {limit: 10, windowSeconds: 15 * 60},
@@ -17,6 +20,9 @@ const policies: Record<AuthRateLimitAction, Record<AuthRateLimitScope, AuthRateL
17
20
  ip: {limit: 30, windowSeconds: 60 * 60},
18
21
  email: {limit: 3, windowSeconds: 60 * 60},
19
22
  },
23
+ bootstrap: {
24
+ ip: {limit: 5, windowSeconds: 15 * 60},
25
+ },
20
26
  };
21
27
 
22
28
  interface EmailBody {
@@ -34,12 +40,15 @@ async function enforceRateLimit(params: {
34
40
  scope: AuthRateLimitScope;
35
41
  identifier: string;
36
42
  }): Promise<void> {
43
+ const policy = policies[params.action][params.scope];
44
+ if (!policy) return;
45
+
37
46
  try {
38
47
  await checkAuthRateLimit({
39
48
  action: params.action,
40
49
  scope: params.scope,
41
50
  identifier: params.identifier,
42
- ...policies[params.action][params.scope],
51
+ ...policy,
43
52
  });
44
53
  } catch (error) {
45
54
  if (error instanceof AuthRateLimitExceededError) {
@@ -117,3 +126,15 @@ export function createAuthRateLimitPreHandler(action: AuthRateLimitAction) {
117
126
  });
118
127
  };
119
128
  }
129
+
130
+ export function createAuthIpRateLimitPreHandler(action: AuthRateLimitAction) {
131
+ return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
132
+ await enforceRateLimit({
133
+ request,
134
+ reply,
135
+ action,
136
+ scope: 'ip',
137
+ identifier: request.ip,
138
+ });
139
+ };
140
+ }
@@ -1,6 +1,9 @@
1
+ import type {WorkspacesInterModuleClient} from '@shipfox/api-workspaces-dto/inter-module';
1
2
  import {workspacesInterModuleContract} from '@shipfox/api-workspaces-dto/inter-module';
2
3
  import {createInterModuleKnownError} from '@shipfox/inter-module';
4
+ import {ClientError} from '@shipfox/node-fastify';
3
5
  import type {FastifyInstance} from 'fastify';
6
+ import {SignupNotAllowedError} from '#core/errors.js';
4
7
  import {verifyUserToken} from '#core/jwt.js';
5
8
  import {
6
9
  acceptWorkspaceInvitationMock,
@@ -14,6 +17,7 @@ import {
14
17
  signup,
15
18
  uniqueEmail,
16
19
  } from '#test/routes.js';
20
+ import {createSignupRoute} from './signup.js';
17
21
 
18
22
  describe('POST /auth/signup', () => {
19
23
  let app: FastifyInstance;
@@ -27,6 +31,28 @@ describe('POST /auth/signup', () => {
27
31
  resetCapturedMail();
28
32
  });
29
33
 
34
+ test('maps a denied signup to a bounded client error', () => {
35
+ const errorHandler = createSignupRoute({} as WorkspacesInterModuleClient).errorHandler as (
36
+ error: unknown,
37
+ ) => never;
38
+ const message = 'x'.repeat(501);
39
+ let mapped: unknown;
40
+
41
+ try {
42
+ errorHandler(new SignupNotAllowedError(message));
43
+ } catch (error) {
44
+ mapped = error;
45
+ }
46
+
47
+ expect(mapped).toBeInstanceOf(ClientError);
48
+ expect(mapped).toMatchObject({
49
+ message: 'x'.repeat(500),
50
+ status: 403,
51
+ code: 'signup-not-allowed',
52
+ details: {message: 'x'.repeat(500)},
53
+ });
54
+ });
55
+
30
56
  afterAll(async () => {
31
57
  await app.close();
32
58
  });