@shipfox/api-workspaces 10.1.0 → 10.2.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +19 -0
- package/README.md +7 -1
- package/dist/core/admin-workspaces.d.ts +16 -0
- package/dist/core/admin-workspaces.d.ts.map +1 -1
- package/dist/core/admin-workspaces.js +52 -0
- package/dist/core/admin-workspaces.js.map +1 -1
- package/dist/core/errors.d.ts +12 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +24 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -2
- package/dist/core/index.js.map +1 -1
- package/dist/db/admin-workspace-commands.d.ts +17 -0
- package/dist/db/admin-workspace-commands.d.ts.map +1 -0
- package/dist/db/admin-workspace-commands.js +86 -0
- package/dist/db/admin-workspace-commands.js.map +1 -0
- package/dist/db/db.d.ts +256 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +2 -0
- package/dist/db/db.js.map +1 -1
- package/dist/db/index.d.ts +1 -0
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -0
- package/dist/db/index.js.map +1 -1
- package/dist/db/schema/admin-command-results.d.ts +135 -0
- package/dist/db/schema/admin-command-results.d.ts.map +1 -0
- package/dist/db/schema/admin-command-results.js +18 -0
- package/dist/db/schema/admin-command-results.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/admin-workspaces.d.ts.map +1 -1
- package/dist/presentation/routes/admin-workspaces.js +119 -14
- package/dist/presentation/routes/admin-workspaces.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0002_wide_enchantress.sql +11 -0
- package/drizzle/meta/0002_snapshot.json +555 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/src/core/admin-workspaces.ts +102 -0
- package/src/core/errors.ts +28 -0
- package/src/core/index.ts +8 -0
- package/src/db/admin-workspace-commands.ts +169 -0
- package/src/db/db.ts +2 -0
- package/src/db/index.ts +1 -0
- package/src/db/schema/admin-command-results.ts +30 -0
- package/src/index.test.ts +3 -1
- package/src/index.ts +17 -2
- package/src/presentation/routes/admin-workspaces.test.ts +273 -3
- package/src/presentation/routes/admin-workspaces.ts +143 -13
- package/test/globalSetup.ts +3 -1
- package/tsconfig.build.tsbuildinfo +1 -1
- package/vitest.config.ts +3 -0
|
@@ -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 {
|
|
12
|
-
import {
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
}
|
package/test/globalSetup.ts
CHANGED
|
@@ -8,7 +8,9 @@ export async function setup() {
|
|
|
8
8
|
createPostgresClient();
|
|
9
9
|
|
|
10
10
|
await runMigrations(db(), migrationsPath, '__drizzle_migrations_workspaces');
|
|
11
|
-
await db().execute(
|
|
11
|
+
await db().execute(
|
|
12
|
+
sql`TRUNCATE workspaces_admin_command_results, workspaces_outbox, workspaces_workspaces CASCADE`,
|
|
13
|
+
);
|
|
12
14
|
|
|
13
15
|
closeDb();
|
|
14
16
|
await closePostgresClient();
|