@shipfox/api-workspaces 10.1.0 → 11.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 (98) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +7 -1
  4. package/dist/core/admin-workspaces.d.ts +16 -0
  5. package/dist/core/admin-workspaces.d.ts.map +1 -1
  6. package/dist/core/admin-workspaces.js +52 -0
  7. package/dist/core/admin-workspaces.js.map +1 -1
  8. package/dist/core/errors.d.ts +12 -0
  9. package/dist/core/errors.d.ts.map +1 -1
  10. package/dist/core/errors.js +24 -0
  11. package/dist/core/errors.js.map +1 -1
  12. package/dist/core/index.d.ts +2 -2
  13. package/dist/core/index.d.ts.map +1 -1
  14. package/dist/core/index.js +2 -2
  15. package/dist/core/index.js.map +1 -1
  16. package/dist/core/invitations.d.ts.map +1 -1
  17. package/dist/core/invitations.js +6 -3
  18. package/dist/core/invitations.js.map +1 -1
  19. package/dist/core/workspaces.d.ts +1 -0
  20. package/dist/core/workspaces.d.ts.map +1 -1
  21. package/dist/core/workspaces.js +5 -3
  22. package/dist/core/workspaces.js.map +1 -1
  23. package/dist/db/admin-workspace-commands.d.ts +17 -0
  24. package/dist/db/admin-workspace-commands.d.ts.map +1 -0
  25. package/dist/db/admin-workspace-commands.js +86 -0
  26. package/dist/db/admin-workspace-commands.js.map +1 -0
  27. package/dist/db/db.d.ts +256 -0
  28. package/dist/db/db.d.ts.map +1 -1
  29. package/dist/db/db.js +2 -0
  30. package/dist/db/db.js.map +1 -1
  31. package/dist/db/index.d.ts +1 -0
  32. package/dist/db/index.d.ts.map +1 -1
  33. package/dist/db/index.js +1 -0
  34. package/dist/db/index.js.map +1 -1
  35. package/dist/db/schema/admin-command-results.d.ts +135 -0
  36. package/dist/db/schema/admin-command-results.d.ts.map +1 -0
  37. package/dist/db/schema/admin-command-results.js +18 -0
  38. package/dist/db/schema/admin-command-results.js.map +1 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +7 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/presentation/inter-module.d.ts.map +1 -1
  43. package/dist/presentation/inter-module.js +3 -2
  44. package/dist/presentation/inter-module.js.map +1 -1
  45. package/dist/presentation/routes/admin-workspaces.d.ts.map +1 -1
  46. package/dist/presentation/routes/admin-workspaces.js +119 -14
  47. package/dist/presentation/routes/admin-workspaces.js.map +1 -1
  48. package/dist/presentation/routes/invitations/create.d.ts.map +1 -1
  49. package/dist/presentation/routes/invitations/create.js +5 -1
  50. package/dist/presentation/routes/invitations/create.js.map +1 -1
  51. package/dist/presentation/routes/invitations/list.d.ts.map +1 -1
  52. package/dist/presentation/routes/invitations/list.js +5 -1
  53. package/dist/presentation/routes/invitations/list.js.map +1 -1
  54. package/dist/presentation/routes/invitations/revoke.d.ts.map +1 -1
  55. package/dist/presentation/routes/invitations/revoke.js +5 -1
  56. package/dist/presentation/routes/invitations/revoke.js.map +1 -1
  57. package/dist/presentation/routes/members/list.d.ts.map +1 -1
  58. package/dist/presentation/routes/members/list.js +5 -1
  59. package/dist/presentation/routes/members/list.js.map +1 -1
  60. package/dist/presentation/routes/members/remove.d.ts.map +1 -1
  61. package/dist/presentation/routes/members/remove.js +5 -1
  62. package/dist/presentation/routes/members/remove.js.map +1 -1
  63. package/dist/tsconfig.test.tsbuildinfo +1 -1
  64. package/drizzle/0002_wide_enchantress.sql +11 -0
  65. package/drizzle/meta/0002_snapshot.json +555 -0
  66. package/drizzle/meta/_journal.json +7 -0
  67. package/package.json +6 -6
  68. package/src/core/admin-workspaces.ts +102 -0
  69. package/src/core/errors.ts +28 -0
  70. package/src/core/index.ts +8 -0
  71. package/src/core/invitations.test.ts +8 -8
  72. package/src/core/invitations.ts +3 -0
  73. package/src/core/workspaces.test.ts +22 -3
  74. package/src/core/workspaces.ts +4 -1
  75. package/src/db/admin-workspace-commands.ts +169 -0
  76. package/src/db/db.ts +2 -0
  77. package/src/db/index.ts +1 -0
  78. package/src/db/schema/admin-command-results.ts +30 -0
  79. package/src/index.test.ts +3 -1
  80. package/src/index.ts +17 -2
  81. package/src/presentation/inter-module.test.ts +51 -0
  82. package/src/presentation/inter-module.ts +7 -4
  83. package/src/presentation/routes/admin-workspaces.test.ts +273 -3
  84. package/src/presentation/routes/admin-workspaces.ts +143 -13
  85. package/src/presentation/routes/invitations/create.test.ts +15 -0
  86. package/src/presentation/routes/invitations/create.ts +2 -1
  87. package/src/presentation/routes/invitations/list.test.ts +30 -0
  88. package/src/presentation/routes/invitations/list.ts +2 -1
  89. package/src/presentation/routes/invitations/revoke.test.ts +14 -0
  90. package/src/presentation/routes/invitations/revoke.ts +2 -1
  91. package/src/presentation/routes/members/list.test.ts +29 -1
  92. package/src/presentation/routes/members/list.ts +2 -1
  93. package/src/presentation/routes/members/remove.test.ts +14 -0
  94. package/src/presentation/routes/members/remove.ts +2 -1
  95. package/test/globalSetup.ts +3 -1
  96. package/test/routes.ts +8 -3
  97. package/tsconfig.build.tsbuildinfo +1 -1
  98. package/vitest.config.ts +3 -0
@@ -1,3 +1,4 @@
1
+ import {buildUserContext, requireWorkspaceAccess, setUserContext} from '@shipfox/api-auth-context';
1
2
  import {workspacesInterModuleContract} from '@shipfox/api-workspaces-dto/inter-module';
2
3
  import {isInterModuleKnownError} from '@shipfox/inter-module';
3
4
  import {createInMemoryInterModuleTransport} from '@shipfox/node-module/inter-module';
@@ -54,6 +55,56 @@ describe('Workspaces inter-module presentation', () => {
54
55
  expect(result).toEqual({creatorUserId: null});
55
56
  });
56
57
 
58
+ test('carries workspace status into token claims', async () => {
59
+ const client = createClient();
60
+ const userId = crypto.randomUUID();
61
+ const active = await createWorkspaceForUser({name: 'Active Workspace', userId});
62
+ const suspended = await createWorkspaceForUser({name: 'Suspended Workspace', userId});
63
+ const deleted = await createWorkspaceForUser({name: 'Deleted Workspace', userId});
64
+ await updateWorkspace({id: suspended.id, status: 'suspended'});
65
+ await updateWorkspace({id: deleted.id, status: 'deleted'});
66
+
67
+ expect(await client.listMembershipsForTokenClaims({userId})).toEqual({
68
+ memberships: [
69
+ {workspaceId: active.id, role: 'admin', workspaceStatus: 'active'},
70
+ {workspaceId: deleted.id, role: 'admin', workspaceStatus: 'deleted'},
71
+ {workspaceId: suspended.id, role: 'admin', workspaceStatus: 'suspended'},
72
+ ],
73
+ });
74
+
75
+ await updateWorkspace({id: suspended.id, status: 'active'});
76
+
77
+ expect(await client.listMembershipsForTokenClaims({userId})).toEqual({
78
+ memberships: [
79
+ {workspaceId: active.id, role: 'admin', workspaceStatus: 'active'},
80
+ {workspaceId: deleted.id, role: 'admin', workspaceStatus: 'deleted'},
81
+ {workspaceId: suspended.id, role: 'admin', workspaceStatus: 'active'},
82
+ ],
83
+ });
84
+ });
85
+
86
+ test('preserves suspended-workspace access errors through the claim boundary', async () => {
87
+ const client = createClient();
88
+ const userId = crypto.randomUUID();
89
+ const workspace = await createWorkspaceForUser({name: 'Suspended Access Workspace', userId});
90
+ await updateWorkspace({id: workspace.id, status: 'suspended'});
91
+
92
+ const claims = await client.listMembershipsForTokenClaims({userId});
93
+ const request = {};
94
+ setUserContext(
95
+ request,
96
+ buildUserContext({
97
+ userId,
98
+ email: `user-${userId}@example.com`,
99
+ memberships: claims.memberships,
100
+ }),
101
+ );
102
+
103
+ expect(() => requireWorkspaceAccess({request, workspaceId: workspace.id})).toThrow(
104
+ expect.objectContaining({code: 'workspace-suspended', status: 409}),
105
+ );
106
+ });
107
+
57
108
  test('returns only the current workspace operating state', async () => {
58
109
  const client = createClient();
59
110
  const workspace = await createWorkspace({name: 'Operating State Workspace'});
@@ -26,10 +26,13 @@ export function createWorkspacesInterModulePresentation(): InterModulePresentati
26
26
  > {
27
27
  return defineInterModulePresentation(workspacesInterModuleContract, {
28
28
  listMembershipsForTokenClaims: async ({userId}) => ({
29
- memberships: (await listMembershipsByUser({userId})).map(({workspaceId}) => ({
30
- workspaceId,
31
- role: 'admin' as const,
32
- })),
29
+ memberships: (await listMembershipsByUser({userId})).map(
30
+ ({workspaceId, workspaceStatus}) => ({
31
+ workspaceId,
32
+ role: 'admin' as const,
33
+ workspaceStatus,
34
+ }),
35
+ ),
33
36
  }),
34
37
  getWorkspaceCreator: async (input) => {
35
38
  try {
@@ -3,17 +3,30 @@ import {
3
3
  type AuthInterModuleClient,
4
4
  authInterModuleContract,
5
5
  } from '@shipfox/api-auth-dto/inter-module';
6
+ import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
6
7
  import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
7
8
  import type {RunnersInterModuleClient} from '@shipfox/api-runners-dto/inter-module';
8
9
  import {createInterModuleKnownError} from '@shipfox/inter-module';
9
10
  import {type AuthMethod, closeApp, createApp} from '@shipfox/node-fastify';
11
+ import {eq, sql} from 'drizzle-orm';
10
12
  import type {FastifyInstance, FastifyRequest} from 'fastify';
11
- import {createMembership} from '#db/memberships.js';
12
- import {createWorkspace} from '#db/workspaces.js';
13
+ import {db} from '#db/db.js';
14
+ import {createInvitation, listOpenInvitationsByWorkspace} from '#db/invitations.js';
15
+ import {createMembership, listMembershipsByWorkspace} from '#db/memberships.js';
16
+ import {workspacesAdminCommandResults} from '#db/schema/admin-command-results.js';
17
+ import {workspacesOutbox} from '#db/schema/outbox.js';
18
+ import {createWorkspace, getWorkspaceById, updateWorkspace} from '#db/workspaces.js';
13
19
  import {createAdminWorkspacesRoutes} from './admin-workspaces.js';
14
20
 
15
21
  const USER_ID = '11111111-1111-4111-8111-111111111111';
16
22
 
23
+ function adminHeaders(idempotencyKey: string) {
24
+ return {
25
+ authorization: 'Bearer user',
26
+ 'idempotency-key': idempotencyKey,
27
+ };
28
+ }
29
+
17
30
  const fakeUserAuth: AuthMethod = {
18
31
  name: AUTH_USER,
19
32
  authenticate: (request: FastifyRequest) => {
@@ -33,8 +46,13 @@ describe('GET /admin/workspaces', () => {
33
46
 
34
47
  beforeEach(async () => {
35
48
  await closeApp();
49
+ await db().execute(
50
+ sql`TRUNCATE workspaces_admin_command_results, workspaces_outbox, workspaces_workspaces CASCADE`,
51
+ );
36
52
  auth = {
37
- requireAdminRole: vi.fn().mockResolvedValue({role: 'admin-observer'}),
53
+ requireAdminRole: vi
54
+ .fn()
55
+ .mockImplementation(({minimumRole}) => Promise.resolve({role: minimumRole})),
38
56
  } as unknown as AuthInterModuleClient;
39
57
  projects = {
40
58
  getWorkspaceProjectCounts: vi.fn().mockResolvedValue({counts: []}),
@@ -92,6 +110,258 @@ describe('GET /admin/workspaces', () => {
92
110
  });
93
111
  });
94
112
 
113
+ test('suspends a workspace without deleting its data and writes one redacted event', async () => {
114
+ const workspace = await createWorkspace({name: `Suspend ${crypto.randomUUID()}`});
115
+ await updateWorkspace({id: workspace.id, settings: {retained: true}});
116
+ await createMembership({userId: crypto.randomUUID(), workspaceId: workspace.id});
117
+ await createInvitation({
118
+ workspaceId: workspace.id,
119
+ email: `pending-${crypto.randomUUID()}@example.com`,
120
+ hashedToken: crypto.randomUUID(),
121
+ expiresAt: new Date(Date.now() + 60_000),
122
+ invitedByUserId: USER_ID,
123
+ skipEmail: true,
124
+ });
125
+
126
+ const response = await app.inject({
127
+ method: 'POST',
128
+ url: `/admin/workspaces/${workspace.id}/suspend`,
129
+ headers: adminHeaders('suspend-workspace'),
130
+ payload: {reason: 'Requested by support'},
131
+ });
132
+
133
+ expect(response.statusCode).toBe(200);
134
+ const body = response.json();
135
+ expect(body).toMatchObject({workspace_id: workspace.id, status: 'suspended'});
136
+ expect(body.correlation_id).toEqual(expect.any(String));
137
+ await expect(getWorkspaceById(workspace.id)).resolves.toMatchObject({
138
+ id: workspace.id,
139
+ status: 'suspended',
140
+ settings: {retained: true},
141
+ });
142
+ await expect(listMembershipsByWorkspace({workspaceId: workspace.id})).resolves.toHaveLength(1);
143
+ await expect(listOpenInvitationsByWorkspace({workspaceId: workspace.id})).resolves.toHaveLength(
144
+ 1,
145
+ );
146
+
147
+ const events = await db()
148
+ .select()
149
+ .from(workspacesOutbox)
150
+ .where(eq(workspacesOutbox.eventType, ADMINISTRATION_ACTION_PERFORMED));
151
+ expect(events).toHaveLength(1);
152
+ expect(events[0]).toMatchObject({
153
+ eventType: ADMINISTRATION_ACTION_PERFORMED,
154
+ payload: {
155
+ actorId: USER_ID,
156
+ actorRole: 'admin-operator',
157
+ requiredRole: 'admin-operator',
158
+ command: 'workspace.suspend',
159
+ targetType: 'workspace',
160
+ targetId: workspace.id,
161
+ reason: 'Requested by support',
162
+ result: 'succeeded',
163
+ correlationId: body.correlation_id,
164
+ },
165
+ });
166
+ expect(events[0]?.payload).not.toHaveProperty('idempotencyKey');
167
+ await expect(
168
+ db()
169
+ .select()
170
+ .from(workspacesAdminCommandResults)
171
+ .where(eq(workspacesAdminCommandResults.actorId, USER_ID)),
172
+ ).resolves.toHaveLength(1);
173
+ });
174
+
175
+ test('returns the committed suspension result on an idempotent retry', async () => {
176
+ const workspace = await createWorkspace({name: `Retry ${crypto.randomUUID()}`});
177
+ const first = await app.inject({
178
+ method: 'POST',
179
+ url: `/admin/workspaces/${workspace.id}/suspend`,
180
+ headers: adminHeaders('retry-workspace-suspend'),
181
+ payload: {reason: 'Retry test'},
182
+ });
183
+ const retry = await app.inject({
184
+ method: 'POST',
185
+ url: `/admin/workspaces/${workspace.id}/suspend`,
186
+ headers: adminHeaders('retry-workspace-suspend'),
187
+ payload: {reason: 'Retry test'},
188
+ });
189
+
190
+ expect(first.statusCode).toBe(200);
191
+ expect(retry.statusCode).toBe(200);
192
+ expect(retry.json()).toEqual(first.json());
193
+ await expect(
194
+ db()
195
+ .select()
196
+ .from(workspacesAdminCommandResults)
197
+ .where(eq(workspacesAdminCommandResults.actorId, USER_ID)),
198
+ ).resolves.toHaveLength(1);
199
+ await expect(
200
+ db()
201
+ .select()
202
+ .from(workspacesOutbox)
203
+ .where(eq(workspacesOutbox.eventType, ADMINISTRATION_ACTION_PERFORMED)),
204
+ ).resolves.toHaveLength(1);
205
+ });
206
+
207
+ test('reactivates a suspended workspace while preserving its membership', async () => {
208
+ const workspace = await createWorkspace({name: `Reactivate ${crypto.randomUUID()}`});
209
+ await createMembership({userId: crypto.randomUUID(), workspaceId: workspace.id});
210
+
211
+ const suspended = await app.inject({
212
+ method: 'POST',
213
+ url: `/admin/workspaces/${workspace.id}/suspend`,
214
+ headers: adminHeaders('reactivation-suspend'),
215
+ payload: {reason: 'Temporary suspension'},
216
+ });
217
+ const reactivated = await app.inject({
218
+ method: 'POST',
219
+ url: `/admin/workspaces/${workspace.id}/reactivate`,
220
+ headers: adminHeaders('reactivation-reactivate'),
221
+ payload: {reason: 'Issue resolved'},
222
+ });
223
+
224
+ expect(suspended.statusCode).toBe(200);
225
+ expect(reactivated.statusCode).toBe(200);
226
+ expect(reactivated.json()).toMatchObject({workspace_id: workspace.id, status: 'active'});
227
+ await expect(getWorkspaceById(workspace.id)).resolves.toMatchObject({status: 'active'});
228
+ await expect(listMembershipsByWorkspace({workspaceId: workspace.id})).resolves.toHaveLength(1);
229
+ await expect(
230
+ db()
231
+ .select()
232
+ .from(workspacesOutbox)
233
+ .where(eq(workspacesOutbox.eventType, ADMINISTRATION_ACTION_PERFORMED)),
234
+ ).resolves.toHaveLength(2);
235
+ });
236
+
237
+ test('rejects reusing an idempotency key for another workspace command', async () => {
238
+ const workspace = await createWorkspace({name: `Reuse ${crypto.randomUUID()}`});
239
+ const suspended = await app.inject({
240
+ method: 'POST',
241
+ url: `/admin/workspaces/${workspace.id}/suspend`,
242
+ headers: adminHeaders('reused-workspace-key'),
243
+ payload: {reason: 'Reuse test'},
244
+ });
245
+ const reused = await app.inject({
246
+ method: 'POST',
247
+ url: `/admin/workspaces/${workspace.id}/reactivate`,
248
+ headers: adminHeaders('reused-workspace-key'),
249
+ payload: {reason: 'Reuse test'},
250
+ });
251
+
252
+ expect(suspended.statusCode).toBe(200);
253
+ expect(reused.statusCode).toBe(409);
254
+ expect(reused.json()).toMatchObject({code: 'idempotency-key-reused'});
255
+ await expect(getWorkspaceById(workspace.id)).resolves.toMatchObject({status: 'suspended'});
256
+ });
257
+
258
+ test.each([
259
+ ['missing', undefined],
260
+ ['blank', ' '],
261
+ ['oversized', 'x'.repeat(257)],
262
+ ])('requires an idempotency key when it is %s', async (_case, idempotencyKey) => {
263
+ const workspace = await createWorkspace({name: `Key ${crypto.randomUUID()}`});
264
+ const response = await app.inject({
265
+ method: 'POST',
266
+ url: `/admin/workspaces/${workspace.id}/suspend`,
267
+ headers: {
268
+ authorization: 'Bearer user',
269
+ ...(idempotencyKey === undefined ? {} : {'idempotency-key': idempotencyKey}),
270
+ },
271
+ payload: {reason: 'Missing key test'},
272
+ });
273
+
274
+ expect(response.statusCode).toBe(400);
275
+ expect(response.json().code).toBe('idempotency-key-required');
276
+ });
277
+
278
+ test.each([
279
+ ['empty', ''],
280
+ ['oversized', 'x'.repeat(513)],
281
+ ['control character', 'Reason\nwith control'],
282
+ ['format character', 'Reason\u202ewith format character'],
283
+ ])('rejects a %s administration reason', async (_case, reason) => {
284
+ const workspace = await createWorkspace({name: `Reason ${crypto.randomUUID()}`});
285
+ const response = await app.inject({
286
+ method: 'POST',
287
+ url: `/admin/workspaces/${workspace.id}/suspend`,
288
+ headers: adminHeaders(`invalid-reason-${crypto.randomUUID()}`),
289
+ payload: {reason},
290
+ });
291
+
292
+ expect(response.statusCode).toBe(400);
293
+ });
294
+
295
+ test('returns not found for an unknown workspace', async () => {
296
+ const response = await app.inject({
297
+ method: 'POST',
298
+ url: `/admin/workspaces/${crypto.randomUUID()}/suspend`,
299
+ headers: adminHeaders('unknown-workspace'),
300
+ payload: {reason: 'Unknown workspace test'},
301
+ });
302
+
303
+ expect(response.statusCode).toBe(404);
304
+ expect(response.json().code).toBe('workspace-not-found');
305
+ });
306
+
307
+ test('rejects suspending an already suspended workspace with a fresh key', async () => {
308
+ const workspace = await createWorkspace({name: `Already suspended ${crypto.randomUUID()}`});
309
+ const first = await app.inject({
310
+ method: 'POST',
311
+ url: `/admin/workspaces/${workspace.id}/suspend`,
312
+ headers: adminHeaders('already-suspended-first'),
313
+ payload: {reason: 'First suspension'},
314
+ });
315
+ const second = await app.inject({
316
+ method: 'POST',
317
+ url: `/admin/workspaces/${workspace.id}/suspend`,
318
+ headers: adminHeaders('already-suspended-second'),
319
+ payload: {reason: 'Second suspension'},
320
+ });
321
+
322
+ expect(first.statusCode).toBe(200);
323
+ expect(second.statusCode).toBe(409);
324
+ expect(second.json().code).toBe('workspace-already-suspended');
325
+ });
326
+
327
+ test('rejects reactivating an active workspace', async () => {
328
+ const workspace = await createWorkspace({name: `Already active ${crypto.randomUUID()}`});
329
+ const response = await app.inject({
330
+ method: 'POST',
331
+ url: `/admin/workspaces/${workspace.id}/reactivate`,
332
+ headers: adminHeaders('already-active-reactivate'),
333
+ payload: {reason: 'Already active test'},
334
+ });
335
+
336
+ expect(response.statusCode).toBe(409);
337
+ expect(response.json().code).toBe('workspace-not-suspended');
338
+ });
339
+
340
+ test('rejects mutating a deleted workspace', async () => {
341
+ const workspace = await createWorkspace({name: `Deleted ${crypto.randomUUID()}`});
342
+ await updateWorkspace({id: workspace.id, status: 'deleted'});
343
+ const response = await app.inject({
344
+ method: 'POST',
345
+ url: `/admin/workspaces/${workspace.id}/suspend`,
346
+ headers: adminHeaders('deleted-workspace'),
347
+ payload: {reason: 'Deleted workspace test'},
348
+ });
349
+
350
+ expect(response.statusCode).toBe(409);
351
+ expect(response.json().code).toBe('workspace-deleted');
352
+ });
353
+
354
+ test('does not expose a versioned administration namespace', async () => {
355
+ const response = await app.inject({
356
+ method: 'POST',
357
+ url: '/admin/v1/workspaces/00000000-0000-4000-8000-000000000001/suspend',
358
+ headers: adminHeaders('versioned-route'),
359
+ payload: {reason: 'Should not route'},
360
+ });
361
+
362
+ expect(response.statusCode).toBe(404);
363
+ });
364
+
95
365
  test('returns explicit unknown job counts when the supporting lookup fails', async () => {
96
366
  const workspace = await createWorkspace({name: `Unknown jobs ${crypto.randomUUID()}`});
97
367
  vi.mocked(projects.getWorkspaceProjectCounts).mockResolvedValue({
@@ -7,15 +7,107 @@ import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module'
7
7
  import type {RunnersInterModuleClient} from '@shipfox/api-runners-dto/inter-module';
8
8
  import {
9
9
  listWorkspaceAdminSummariesResponseSchema,
10
+ workspaceAdministrationMutationBodySchema,
11
+ workspaceAdministrationMutationResponseSchema,
10
12
  workspaceAdminLookupQuerySchema,
11
13
  } from '@shipfox/api-workspaces-dto';
12
14
  import {isInterModuleKnownError} from '@shipfox/inter-module';
13
15
  import {decodeStringIdCursor, encodeStringIdCursor} from '@shipfox/node-drizzle';
14
16
  import {ClientError, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
17
+ import type {FastifyRequest} from 'fastify';
18
+ import {z} from 'zod';
15
19
  import {
16
20
  listWorkspaceAdministratorSummaries,
21
+ reactivateWorkspace,
22
+ suspendWorkspace,
23
+ type WorkspaceAdministrationMutationContext,
17
24
  type WorkspaceAdministratorSummary,
18
25
  } from '#core/admin-workspaces.js';
26
+ import {
27
+ WorkspaceAdminIdempotencyKeyReuseError,
28
+ WorkspaceAlreadySuspendedError,
29
+ WorkspaceDeletedError,
30
+ WorkspaceNotFoundError,
31
+ WorkspaceNotSuspendedError,
32
+ } from '#core/errors.js';
33
+
34
+ const idempotencyKeyMaxLength = 256;
35
+
36
+ function requireActorId(request: FastifyRequest): string {
37
+ const client = getUserContext(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 translateAdminRoleError(error: unknown, message: string): void {
56
+ if (
57
+ isInterModuleKnownError(authInterModuleContract.methods.requireAdminRole, error) &&
58
+ error.code === 'admin-role-required'
59
+ ) {
60
+ throw new ClientError(message, 'forbidden', {
61
+ status: 403,
62
+ details: {required_role: error.details.requiredRole},
63
+ });
64
+ }
65
+ }
66
+
67
+ function translateAdministrationError(error: unknown): never {
68
+ translateAdminRoleError(error, 'Administrator operator role required');
69
+ if (error instanceof WorkspaceNotFoundError) {
70
+ throw new ClientError('Workspace not found', 'workspace-not-found', {status: 404});
71
+ }
72
+ if (error instanceof WorkspaceDeletedError) {
73
+ throw new ClientError('Workspace is deleted', 'workspace-deleted', {status: 409});
74
+ }
75
+ if (error instanceof WorkspaceAlreadySuspendedError) {
76
+ throw new ClientError('Workspace is already suspended', 'workspace-already-suspended', {
77
+ status: 409,
78
+ });
79
+ }
80
+ if (error instanceof WorkspaceNotSuspendedError) {
81
+ throw new ClientError('Workspace is not suspended', 'workspace-not-suspended', {
82
+ status: 409,
83
+ });
84
+ }
85
+ if (error instanceof WorkspaceAdminIdempotencyKeyReuseError) {
86
+ throw new ClientError(
87
+ 'Idempotency-Key was already used for a different workspace command',
88
+ 'idempotency-key-reused',
89
+ {status: 409},
90
+ );
91
+ }
92
+ throw error;
93
+ }
94
+
95
+ function translateObserverAdministrationError(error: unknown): never {
96
+ translateAdminRoleError(error, 'Administrator observer role required');
97
+ throw error;
98
+ }
99
+
100
+ function toWorkspaceAdministrationMutationDto(result: {
101
+ workspaceId: string;
102
+ status: 'active' | 'suspended';
103
+ correlationId: string;
104
+ }) {
105
+ return {
106
+ workspace_id: result.workspaceId,
107
+ status: result.status,
108
+ correlation_id: result.correlationId,
109
+ };
110
+ }
19
111
 
20
112
  function toWorkspaceAdministratorSummaryDto(summary: WorkspaceAdministratorSummary) {
21
113
  return {
@@ -44,18 +136,7 @@ export function createAdminWorkspacesRoutes(params: {
44
136
  querystring: workspaceAdminLookupQuerySchema,
45
137
  response: {200: listWorkspaceAdminSummariesResponseSchema},
46
138
  },
47
- errorHandler: (error) => {
48
- if (
49
- isInterModuleKnownError(authInterModuleContract.methods.requireAdminRole, error) &&
50
- error.code === 'admin-role-required'
51
- ) {
52
- throw new ClientError('Administrator observer role required', 'forbidden', {
53
- status: 403,
54
- details: {required_role: error.details.requiredRole},
55
- });
56
- }
57
- throw error;
58
- },
139
+ errorHandler: translateObserverAdministrationError,
59
140
  handler: async (request) => {
60
141
  const client = getUserContext(request);
61
142
  if (!client) {
@@ -89,5 +170,54 @@ export function createAdminWorkspacesRoutes(params: {
89
170
  },
90
171
  });
91
172
 
92
- return {prefix: '/admin/workspaces', routes: [listRoute]};
173
+ const createMutationRoute = (routeParams: {
174
+ action: 'suspend' | 'reactivate';
175
+ description: string;
176
+ execute: (context: WorkspaceAdministrationMutationContext) => Promise<{
177
+ workspaceId: string;
178
+ status: 'active' | 'suspended';
179
+ correlationId: string;
180
+ }>;
181
+ }) =>
182
+ defineRoute({
183
+ method: 'POST',
184
+ path: `/:workspaceId/${routeParams.action}`,
185
+ description: routeParams.description,
186
+ auth: AUTH_USER,
187
+ schema: {
188
+ params: z.object({workspaceId: z.string().uuid()}),
189
+ body: workspaceAdministrationMutationBodySchema,
190
+ response: {200: workspaceAdministrationMutationResponseSchema},
191
+ },
192
+ errorHandler: translateAdministrationError,
193
+ handler: async (request) => {
194
+ const actorId = requireActorId(request);
195
+ const actorRole = (
196
+ await params.auth.requireAdminRole({userId: actorId, minimumRole: 'admin-operator'})
197
+ ).role;
198
+ return toWorkspaceAdministrationMutationDto(
199
+ await routeParams.execute({
200
+ actorId,
201
+ actorRole,
202
+ workspaceId: request.params.workspaceId,
203
+ reason: request.body.reason,
204
+ idempotencyKey: requireIdempotencyKey(request),
205
+ correlationId: request.id,
206
+ }),
207
+ );
208
+ },
209
+ });
210
+
211
+ const suspendRoute = createMutationRoute({
212
+ action: 'suspend',
213
+ description: 'Suspend a workspace for reversible administrator moderation.',
214
+ execute: suspendWorkspace,
215
+ });
216
+ const reactivateRoute = createMutationRoute({
217
+ action: 'reactivate',
218
+ description: 'Reactivate a suspended workspace.',
219
+ execute: reactivateWorkspace,
220
+ });
221
+
222
+ return {prefix: '/admin/workspaces', routes: [listRoute, suspendRoute, reactivateRoute]};
93
223
  }
@@ -66,6 +66,21 @@ describe('POST /workspaces/:workspaceId/invitations', () => {
66
66
  expect(await invitationOutboxEventsTo(inviteeEmail)).toHaveLength(1);
67
67
  });
68
68
 
69
+ test('returns workspace-suspended for a suspended membership claim', async () => {
70
+ const workspaceId = crypto.randomUUID();
71
+ const token = `claim:${crypto.randomUUID()}:suspended-invite-create@example.com:${workspaceId}:suspended`;
72
+
73
+ const res = await app.inject({
74
+ method: 'POST',
75
+ url: `/workspaces/${workspaceId}/invitations`,
76
+ headers: {authorization: `Bearer ${token}`},
77
+ payload: {email: uniqueEmail('suspended-invite')},
78
+ });
79
+
80
+ expect(res.statusCode).toBe(409);
81
+ expect(res.json().code).toBe('workspace-suspended');
82
+ });
83
+
69
84
  test('transforms a whitespace/case-equivalent duplicate open invitation into 409', async () => {
70
85
  const owner = await signupVerifyLogin(app, 'invite-create-duplicate-equivalent');
71
86
  const workspaceId = await createWorkspace(app, owner.token);
@@ -1,4 +1,4 @@
1
- import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
1
+ import {AUTH_USER, getUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';
2
2
  import {createInvitationBodySchema, invitationDtoSchema} from '@shipfox/api-workspaces-dto';
3
3
  import {ClientError, defineRoute} from '@shipfox/node-fastify';
4
4
  import {z} from 'zod';
@@ -45,6 +45,7 @@ export const createInvitationRoute = defineRoute({
45
45
  }
46
46
 
47
47
  const {workspaceId} = request.params;
48
+ requireWorkspaceAccess({request, workspaceId});
48
49
  const {email} = request.body;
49
50
  const invitation = await createWorkspaceInvitation({
50
51
  workspaceId,
@@ -1,4 +1,5 @@
1
1
  import type {FastifyInstance} from 'fastify';
2
+ import {updateWorkspace} from '#db/workspaces.js';
2
3
  import {
3
4
  createExpiredInvite,
4
5
  createInvite,
@@ -46,6 +47,21 @@ describe('GET /workspaces/:workspaceId/invitations', () => {
46
47
  ]);
47
48
  });
48
49
 
50
+ test('keeps an active claim usable when the workspace row is suspended', async () => {
51
+ const owner = await signupVerifyLogin(app, 'invite-list-snapshot');
52
+ const workspaceId = await createWorkspace(app, owner.token);
53
+ await updateWorkspace({id: workspaceId, status: 'suspended'});
54
+
55
+ const res = await app.inject({
56
+ method: 'GET',
57
+ url: `/workspaces/${workspaceId}/invitations`,
58
+ headers: {authorization: `Bearer ${owner.token}`},
59
+ });
60
+
61
+ expect(res.statusCode).toBe(200);
62
+ expect(res.json().invitations).toEqual([]);
63
+ });
64
+
49
65
  test('excludes expired unaccepted invitations', async () => {
50
66
  const owner = await signupVerifyLogin(app, 'invite-list-expired-owner');
51
67
  const workspaceId = await createWorkspace(app, owner.token);
@@ -65,6 +81,20 @@ describe('GET /workspaces/:workspaceId/invitations', () => {
65
81
  expect(res.json().invitations).toEqual([]);
66
82
  });
67
83
 
84
+ test('returns workspace-suspended for a suspended membership claim', async () => {
85
+ const workspaceId = crypto.randomUUID();
86
+ const token = `claim:${crypto.randomUUID()}:suspended-invite-list@example.com:${workspaceId}:suspended`;
87
+
88
+ const res = await app.inject({
89
+ method: 'GET',
90
+ url: `/workspaces/${workspaceId}/invitations`,
91
+ headers: {authorization: `Bearer ${token}`},
92
+ });
93
+
94
+ expect(res.statusCode).toBe(409);
95
+ expect(res.json().code).toBe('workspace-suspended');
96
+ });
97
+
68
98
  test('transforms missing membership into 403', async () => {
69
99
  const outsider = await signupVerifyLogin(app, 'invite-list-outsider');
70
100
  const workspaceId = crypto.randomUUID();
@@ -1,4 +1,4 @@
1
- import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
1
+ import {AUTH_USER, getUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';
2
2
  import {listInvitationsResponseSchema} from '@shipfox/api-workspaces-dto';
3
3
  import {ClientError, defineRoute} from '@shipfox/node-fastify';
4
4
  import {z} from 'zod';
@@ -40,6 +40,7 @@ export const listInvitationsRoute = defineRoute({
40
40
  }
41
41
 
42
42
  const {workspaceId} = request.params;
43
+ requireWorkspaceAccess({request, workspaceId});
43
44
  const invitations = await listWorkspaceInvitations({
44
45
  workspaceId,
45
46
  requesterUserId: client.userId,