@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +33 -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/core/invitations.d.ts.map +1 -1
- package/dist/core/invitations.js +6 -3
- package/dist/core/invitations.js.map +1 -1
- package/dist/core/workspaces.d.ts +1 -0
- package/dist/core/workspaces.d.ts.map +1 -1
- package/dist/core/workspaces.js +5 -3
- package/dist/core/workspaces.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/inter-module.d.ts.map +1 -1
- package/dist/presentation/inter-module.js +3 -2
- package/dist/presentation/inter-module.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/presentation/routes/invitations/create.d.ts.map +1 -1
- package/dist/presentation/routes/invitations/create.js +5 -1
- package/dist/presentation/routes/invitations/create.js.map +1 -1
- package/dist/presentation/routes/invitations/list.d.ts.map +1 -1
- package/dist/presentation/routes/invitations/list.js +5 -1
- package/dist/presentation/routes/invitations/list.js.map +1 -1
- package/dist/presentation/routes/invitations/revoke.d.ts.map +1 -1
- package/dist/presentation/routes/invitations/revoke.js +5 -1
- package/dist/presentation/routes/invitations/revoke.js.map +1 -1
- package/dist/presentation/routes/members/list.d.ts.map +1 -1
- package/dist/presentation/routes/members/list.js +5 -1
- package/dist/presentation/routes/members/list.js.map +1 -1
- package/dist/presentation/routes/members/remove.d.ts.map +1 -1
- package/dist/presentation/routes/members/remove.js +5 -1
- package/dist/presentation/routes/members/remove.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 +6 -6
- package/src/core/admin-workspaces.ts +102 -0
- package/src/core/errors.ts +28 -0
- package/src/core/index.ts +8 -0
- package/src/core/invitations.test.ts +8 -8
- package/src/core/invitations.ts +3 -0
- package/src/core/workspaces.test.ts +22 -3
- package/src/core/workspaces.ts +4 -1
- 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/inter-module.test.ts +51 -0
- package/src/presentation/inter-module.ts +7 -4
- package/src/presentation/routes/admin-workspaces.test.ts +273 -3
- package/src/presentation/routes/admin-workspaces.ts +143 -13
- package/src/presentation/routes/invitations/create.test.ts +15 -0
- package/src/presentation/routes/invitations/create.ts +2 -1
- package/src/presentation/routes/invitations/list.test.ts +30 -0
- package/src/presentation/routes/invitations/list.ts +2 -1
- package/src/presentation/routes/invitations/revoke.test.ts +14 -0
- package/src/presentation/routes/invitations/revoke.ts +2 -1
- package/src/presentation/routes/members/list.test.ts +29 -1
- package/src/presentation/routes/members/list.ts +2 -1
- package/src/presentation/routes/members/remove.test.ts +14 -0
- package/src/presentation/routes/members/remove.ts +2 -1
- package/test/globalSetup.ts +3 -1
- package/test/routes.ts +8 -3
- package/tsconfig.build.tsbuildinfo +1 -1
- package/vitest.config.ts +3 -0
|
@@ -1,9 +1,111 @@
|
|
|
1
|
+
import {type AdministrationRole, createAdministrationActionEvent} from '@shipfox/api-common-dto';
|
|
1
2
|
import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
|
|
2
3
|
import type {RunnersInterModuleClient} from '@shipfox/api-runners-dto/inter-module';
|
|
3
4
|
import type {StringIdCursor} from '@shipfox/node-drizzle';
|
|
4
5
|
import {logger} from '@shipfox/node-opentelemetry';
|
|
6
|
+
import {hashOpaqueToken} from '@shipfox/node-tokens';
|
|
7
|
+
import {
|
|
8
|
+
reactivateWorkspaceWithAudit,
|
|
9
|
+
suspendWorkspaceWithAudit,
|
|
10
|
+
} from '#db/admin-workspace-commands.js';
|
|
5
11
|
import {listAdminWorkspaces as listAdminWorkspaceRows} from '#db/workspaces.js';
|
|
6
12
|
|
|
13
|
+
const ADMIN_OPERATOR_ROLE: AdministrationRole = 'admin-operator';
|
|
14
|
+
const SUSPEND_COMMAND = 'workspace.suspend';
|
|
15
|
+
const REACTIVATE_COMMAND = 'workspace.reactivate';
|
|
16
|
+
|
|
17
|
+
export interface WorkspaceAdministrationMutationContext {
|
|
18
|
+
actorId: string;
|
|
19
|
+
actorRole: AdministrationRole;
|
|
20
|
+
idempotencyKey: string;
|
|
21
|
+
correlationId: string;
|
|
22
|
+
workspaceId: string;
|
|
23
|
+
reason: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WorkspaceAdministrationMutationResult {
|
|
27
|
+
workspaceId: string;
|
|
28
|
+
status: 'active' | 'suspended';
|
|
29
|
+
correlationId: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function commandFingerprint(
|
|
33
|
+
command: string,
|
|
34
|
+
params: WorkspaceAdministrationMutationContext,
|
|
35
|
+
): string {
|
|
36
|
+
return hashOpaqueToken(
|
|
37
|
+
`${command}:${JSON.stringify({workspaceId: params.workspaceId, reason: params.reason})}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function administrationEvent(params: {
|
|
42
|
+
actorId: string;
|
|
43
|
+
actorRole: AdministrationRole;
|
|
44
|
+
command: string;
|
|
45
|
+
workspaceId: string;
|
|
46
|
+
reason: string;
|
|
47
|
+
correlationId: string;
|
|
48
|
+
idempotencyKeyFingerprint: string;
|
|
49
|
+
}) {
|
|
50
|
+
return createAdministrationActionEvent({
|
|
51
|
+
actorId: params.actorId,
|
|
52
|
+
actorRole: params.actorRole,
|
|
53
|
+
requiredRole: ADMIN_OPERATOR_ROLE,
|
|
54
|
+
command: params.command,
|
|
55
|
+
targetType: 'workspace',
|
|
56
|
+
targetId: params.workspaceId,
|
|
57
|
+
reason: params.reason,
|
|
58
|
+
result: 'succeeded',
|
|
59
|
+
correlationId: params.correlationId,
|
|
60
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
61
|
+
occurredAt: new Date().toISOString(),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function runWorkspaceAdministrationMutation(
|
|
66
|
+
params: WorkspaceAdministrationMutationContext,
|
|
67
|
+
command: string,
|
|
68
|
+
update: typeof suspendWorkspaceWithAudit,
|
|
69
|
+
): Promise<WorkspaceAdministrationMutationResult> {
|
|
70
|
+
const idempotencyKeyFingerprint = hashOpaqueToken(params.idempotencyKey);
|
|
71
|
+
const result = await update({
|
|
72
|
+
actorId: params.actorId,
|
|
73
|
+
workspaceId: params.workspaceId,
|
|
74
|
+
idempotencyKeyFingerprint,
|
|
75
|
+
requestFingerprint: commandFingerprint(command, params),
|
|
76
|
+
event: administrationEvent({
|
|
77
|
+
actorId: params.actorId,
|
|
78
|
+
actorRole: params.actorRole,
|
|
79
|
+
command,
|
|
80
|
+
workspaceId: params.workspaceId,
|
|
81
|
+
reason: params.reason,
|
|
82
|
+
correlationId: params.correlationId,
|
|
83
|
+
idempotencyKeyFingerprint,
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function suspendWorkspace(
|
|
90
|
+
params: WorkspaceAdministrationMutationContext,
|
|
91
|
+
): Promise<WorkspaceAdministrationMutationResult> {
|
|
92
|
+
return await runWorkspaceAdministrationMutation(
|
|
93
|
+
params,
|
|
94
|
+
SUSPEND_COMMAND,
|
|
95
|
+
suspendWorkspaceWithAudit,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function reactivateWorkspace(
|
|
100
|
+
params: WorkspaceAdministrationMutationContext,
|
|
101
|
+
): Promise<WorkspaceAdministrationMutationResult> {
|
|
102
|
+
return await runWorkspaceAdministrationMutation(
|
|
103
|
+
params,
|
|
104
|
+
REACTIVATE_COMMAND,
|
|
105
|
+
reactivateWorkspaceWithAudit,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
7
109
|
export interface WorkspaceAdministratorSummary {
|
|
8
110
|
id: string;
|
|
9
111
|
name: string;
|
package/src/core/errors.ts
CHANGED
|
@@ -12,6 +12,34 @@ export class WorkspaceInactiveError extends Error {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export class WorkspaceAlreadySuspendedError extends Error {
|
|
16
|
+
constructor(workspaceId: string) {
|
|
17
|
+
super(`Workspace is already suspended: ${workspaceId}`);
|
|
18
|
+
this.name = 'WorkspaceAlreadySuspendedError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class WorkspaceNotSuspendedError extends Error {
|
|
23
|
+
constructor(workspaceId: string) {
|
|
24
|
+
super(`Workspace is not suspended: ${workspaceId}`);
|
|
25
|
+
this.name = 'WorkspaceNotSuspendedError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class WorkspaceDeletedError extends Error {
|
|
30
|
+
constructor(workspaceId: string) {
|
|
31
|
+
super(`Workspace is deleted: ${workspaceId}`);
|
|
32
|
+
this.name = 'WorkspaceDeletedError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class WorkspaceAdminIdempotencyKeyReuseError extends Error {
|
|
37
|
+
constructor() {
|
|
38
|
+
super('Idempotency key was already used for a different workspace command');
|
|
39
|
+
this.name = 'WorkspaceAdminIdempotencyKeyReuseError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
15
43
|
export class InvitationNotFoundError extends Error {
|
|
16
44
|
constructor(id: string) {
|
|
17
45
|
super(`Invitation not found: ${id}`);
|
package/src/core/index.ts
CHANGED
|
@@ -2,6 +2,10 @@ export {
|
|
|
2
2
|
type ListWorkspaceAdministratorSummariesParams,
|
|
3
3
|
type ListWorkspaceAdministratorSummariesResult,
|
|
4
4
|
listWorkspaceAdministratorSummaries,
|
|
5
|
+
reactivateWorkspace,
|
|
6
|
+
suspendWorkspace,
|
|
7
|
+
type WorkspaceAdministrationMutationContext,
|
|
8
|
+
type WorkspaceAdministrationMutationResult,
|
|
5
9
|
type WorkspaceAdministratorSummary,
|
|
6
10
|
} from './admin-workspaces.js';
|
|
7
11
|
export type {Invitation} from './entities/invitation.js';
|
|
@@ -18,7 +22,11 @@ export {
|
|
|
18
22
|
SelfRemovalNotAllowedError,
|
|
19
23
|
TokenAlreadyUsedError,
|
|
20
24
|
TokenExpiredError,
|
|
25
|
+
WorkspaceAdminIdempotencyKeyReuseError,
|
|
26
|
+
WorkspaceAlreadySuspendedError,
|
|
27
|
+
WorkspaceDeletedError,
|
|
21
28
|
WorkspaceNotFoundError,
|
|
29
|
+
WorkspaceNotSuspendedError,
|
|
22
30
|
} from './errors.js';
|
|
23
31
|
export {
|
|
24
32
|
acceptWorkspaceInvitation,
|
|
@@ -105,7 +105,7 @@ describe('invitations core', () => {
|
|
|
105
105
|
workspaceId: workspace.id,
|
|
106
106
|
email,
|
|
107
107
|
invitedByUserId: inviter.userId,
|
|
108
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
108
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
expect(invitation.email).toBe(email);
|
|
@@ -129,7 +129,7 @@ describe('invitations core', () => {
|
|
|
129
129
|
email,
|
|
130
130
|
invitedByUserId: inviter.userId,
|
|
131
131
|
invitedByDisplay: 'Dana Scully',
|
|
132
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
132
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
const payload = await latestInvitationPayload(email);
|
|
@@ -157,7 +157,7 @@ describe('invitations core', () => {
|
|
|
157
157
|
workspaceId: workspace.id,
|
|
158
158
|
email,
|
|
159
159
|
invitedByUserId: inviter.userId,
|
|
160
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
160
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
161
161
|
});
|
|
162
162
|
|
|
163
163
|
const payload = await latestInvitationPayload(email);
|
|
@@ -181,7 +181,7 @@ describe('invitations core', () => {
|
|
|
181
181
|
email,
|
|
182
182
|
invitedByUserId: inviter.userId,
|
|
183
183
|
invitedByDisplay: ' ',
|
|
184
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
184
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
185
185
|
});
|
|
186
186
|
|
|
187
187
|
const payload = await latestInvitationPayload(email);
|
|
@@ -204,14 +204,14 @@ describe('invitations core', () => {
|
|
|
204
204
|
workspaceId: workspace.id,
|
|
205
205
|
email,
|
|
206
206
|
invitedByUserId: inviter.userId,
|
|
207
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
207
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
const promise = createWorkspaceInvitation({
|
|
211
211
|
workspaceId: workspace.id,
|
|
212
212
|
email,
|
|
213
213
|
invitedByUserId: inviter.userId,
|
|
214
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
214
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
215
215
|
});
|
|
216
216
|
|
|
217
217
|
await expect(promise).rejects.toBeInstanceOf(OpenInvitationExistsError);
|
|
@@ -233,7 +233,7 @@ describe('invitations core', () => {
|
|
|
233
233
|
workspaceId: workspace.id,
|
|
234
234
|
email: invitee.email,
|
|
235
235
|
invitedByUserId: inviter.userId,
|
|
236
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
236
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
237
237
|
});
|
|
238
238
|
const token = extractToken((await latestInvitationPayload(invitee.email)).inviteLink);
|
|
239
239
|
|
|
@@ -269,7 +269,7 @@ describe('invitations core', () => {
|
|
|
269
269
|
workspaceId: workspace.id,
|
|
270
270
|
email,
|
|
271
271
|
invitedByUserId: inviter.userId,
|
|
272
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
272
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
273
273
|
});
|
|
274
274
|
const payload = await latestInvitationPayload(email);
|
|
275
275
|
const token = extractToken(payload.inviteLink);
|
package/src/core/invitations.ts
CHANGED
|
@@ -46,6 +46,7 @@ export async function createWorkspaceInvitation(params: {
|
|
|
46
46
|
workspaceId: params.workspaceId,
|
|
47
47
|
userId: params.invitedByUserId,
|
|
48
48
|
memberships: params.invitedByMemberships,
|
|
49
|
+
enforceWorkspaceStatus: false,
|
|
49
50
|
});
|
|
50
51
|
|
|
51
52
|
const rawToken = generateOpaqueToken('invitation');
|
|
@@ -216,6 +217,7 @@ export async function listWorkspaceInvitations(params: {
|
|
|
216
217
|
workspaceId: params.workspaceId,
|
|
217
218
|
userId: params.requesterUserId,
|
|
218
219
|
memberships: params.requesterMemberships,
|
|
220
|
+
enforceWorkspaceStatus: false,
|
|
219
221
|
});
|
|
220
222
|
|
|
221
223
|
return listOpenInvitationsByWorkspace({workspaceId: params.workspaceId});
|
|
@@ -231,6 +233,7 @@ export async function revokeWorkspaceInvitation(params: {
|
|
|
231
233
|
workspaceId: params.workspaceId,
|
|
232
234
|
userId: params.requesterUserId,
|
|
233
235
|
memberships: params.requesterMemberships,
|
|
236
|
+
enforceWorkspaceStatus: false,
|
|
234
237
|
});
|
|
235
238
|
|
|
236
239
|
const invitation = await findInvitationById({id: params.invitationId});
|
|
@@ -5,7 +5,7 @@ import {findMembership} from '#db/memberships.js';
|
|
|
5
5
|
import {workspacesOutbox} from '#db/schema/outbox.js';
|
|
6
6
|
import {updateWorkspace} from '#db/workspaces.js';
|
|
7
7
|
import {userFactory} from '#test/index.js';
|
|
8
|
-
import {MembershipRequiredError, WorkspaceNotFoundError} from './errors.js';
|
|
8
|
+
import {MembershipRequiredError, WorkspaceInactiveError, WorkspaceNotFoundError} from './errors.js';
|
|
9
9
|
import {
|
|
10
10
|
createWorkspaceForUser,
|
|
11
11
|
getWorkspaceOperatingState,
|
|
@@ -64,7 +64,7 @@ describe('workspaces core', () => {
|
|
|
64
64
|
const result = await requireWorkspaceMembership({
|
|
65
65
|
workspaceId: workspace.id,
|
|
66
66
|
userId: user.userId,
|
|
67
|
-
memberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
67
|
+
memberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
expect(result.workspace.id).toBe(workspace.id);
|
|
@@ -85,6 +85,25 @@ describe('workspaces core', () => {
|
|
|
85
85
|
expect(await getWorkspaceOperatingState({workspaceId: workspace.id})).toBe('suspended');
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
test('requireWorkspaceMembership rejects a suspended workspace', async () => {
|
|
89
|
+
const user = userFactory.build();
|
|
90
|
+
const workspace = await createWorkspaceForUser({
|
|
91
|
+
name: 'Suspended Workspace',
|
|
92
|
+
userId: user.userId,
|
|
93
|
+
userEmail: user.email,
|
|
94
|
+
userName: user.name,
|
|
95
|
+
});
|
|
96
|
+
await updateWorkspace({id: workspace.id, status: 'suspended'});
|
|
97
|
+
|
|
98
|
+
const membership = requireWorkspaceMembership({
|
|
99
|
+
workspaceId: workspace.id,
|
|
100
|
+
userId: user.userId,
|
|
101
|
+
memberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await expect(membership).rejects.toBeInstanceOf(WorkspaceInactiveError);
|
|
105
|
+
});
|
|
106
|
+
|
|
88
107
|
test('requireWorkspaceMembership rejects when memberships do not include the workspace', async () => {
|
|
89
108
|
const owner = userFactory.build();
|
|
90
109
|
const outsider = userFactory.build();
|
|
@@ -110,7 +129,7 @@ describe('workspaces core', () => {
|
|
|
110
129
|
const missingWorkspace = requireWorkspaceMembership({
|
|
111
130
|
workspaceId: ghostId,
|
|
112
131
|
userId: owner.userId,
|
|
113
|
-
memberships: [{workspaceId: ghostId, role: 'admin'}],
|
|
132
|
+
memberships: [{workspaceId: ghostId, role: 'admin', workspaceStatus: 'active'}],
|
|
114
133
|
});
|
|
115
134
|
|
|
116
135
|
await expect(missingWorkspace).rejects.toBeInstanceOf(WorkspaceNotFoundError);
|
package/src/core/workspaces.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface RequireWorkspaceMembershipParams {
|
|
|
31
31
|
workspaceId: string;
|
|
32
32
|
userId: string;
|
|
33
33
|
memberships: ReadonlyArray<UserContextMembership>;
|
|
34
|
+
enforceWorkspaceStatus?: boolean;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export interface RequireWorkspaceMembershipResult {
|
|
@@ -52,7 +53,7 @@ export async function requireWorkspaceMembership(
|
|
|
52
53
|
if (!workspace) {
|
|
53
54
|
throw new WorkspaceNotFoundError(params.workspaceId);
|
|
54
55
|
}
|
|
55
|
-
if (workspace.status !== 'active') {
|
|
56
|
+
if (params.enforceWorkspaceStatus !== false && workspace.status !== 'active') {
|
|
56
57
|
throw new WorkspaceInactiveError(params.workspaceId);
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -128,6 +129,7 @@ export async function listWorkspaceMembers(params: {
|
|
|
128
129
|
workspaceId: params.workspaceId,
|
|
129
130
|
userId: params.requesterUserId,
|
|
130
131
|
memberships: params.requesterMemberships,
|
|
132
|
+
enforceWorkspaceStatus: false,
|
|
131
133
|
});
|
|
132
134
|
|
|
133
135
|
return listMembershipsByWorkspace({workspaceId: params.workspaceId});
|
|
@@ -143,6 +145,7 @@ export async function removeWorkspaceMember(params: {
|
|
|
143
145
|
workspaceId: params.workspaceId,
|
|
144
146
|
userId: params.requesterUserId,
|
|
145
147
|
memberships: params.requesterMemberships,
|
|
148
|
+
enforceWorkspaceStatus: false,
|
|
146
149
|
});
|
|
147
150
|
|
|
148
151
|
if (params.userId === params.requesterUserId) {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdministrationActionEvent,
|
|
3
|
+
AdministrationActionEventMap,
|
|
4
|
+
} from '@shipfox/api-common-dto';
|
|
5
|
+
import {writeOutboxEvent} from '@shipfox/node-outbox';
|
|
6
|
+
import {and, eq, sql} from 'drizzle-orm';
|
|
7
|
+
import {
|
|
8
|
+
WorkspaceAdminIdempotencyKeyReuseError,
|
|
9
|
+
WorkspaceAlreadySuspendedError,
|
|
10
|
+
WorkspaceDeletedError,
|
|
11
|
+
WorkspaceNotFoundError,
|
|
12
|
+
WorkspaceNotSuspendedError,
|
|
13
|
+
} from '#core/errors.js';
|
|
14
|
+
import {db} from './db.js';
|
|
15
|
+
import {
|
|
16
|
+
type WorkspaceAdminCommandResultDb,
|
|
17
|
+
workspacesAdminCommandResults,
|
|
18
|
+
} from './schema/admin-command-results.js';
|
|
19
|
+
import {workspacesOutbox} from './schema/outbox.js';
|
|
20
|
+
import {workspaces} from './schema/workspaces.js';
|
|
21
|
+
|
|
22
|
+
type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
|
|
23
|
+
|
|
24
|
+
export interface WorkspaceAdministrationResult {
|
|
25
|
+
workspaceId: string;
|
|
26
|
+
status: 'active' | 'suspended';
|
|
27
|
+
correlationId: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface AuditedWorkspaceCommandParams {
|
|
31
|
+
actorId: string;
|
|
32
|
+
idempotencyKeyFingerprint: string;
|
|
33
|
+
requestFingerprint: string;
|
|
34
|
+
event: AdministrationActionEvent;
|
|
35
|
+
workspaceId: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function loadCommandResult(
|
|
39
|
+
tx: Tx,
|
|
40
|
+
params: Pick<AuditedWorkspaceCommandParams, 'actorId' | 'idempotencyKeyFingerprint'> & {
|
|
41
|
+
command: string;
|
|
42
|
+
requestFingerprint: string;
|
|
43
|
+
},
|
|
44
|
+
): Promise<WorkspaceAdministrationResult | undefined> {
|
|
45
|
+
const rows = await tx
|
|
46
|
+
.select()
|
|
47
|
+
.from(workspacesAdminCommandResults)
|
|
48
|
+
.where(
|
|
49
|
+
and(
|
|
50
|
+
eq(workspacesAdminCommandResults.actorId, params.actorId),
|
|
51
|
+
eq(
|
|
52
|
+
workspacesAdminCommandResults.idempotencyKeyFingerprint,
|
|
53
|
+
params.idempotencyKeyFingerprint,
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
.limit(1);
|
|
58
|
+
const result: WorkspaceAdminCommandResultDb | undefined = rows[0];
|
|
59
|
+
if (!result) return undefined;
|
|
60
|
+
if (
|
|
61
|
+
result.command !== params.command ||
|
|
62
|
+
result.requestFingerprint !== params.requestFingerprint
|
|
63
|
+
) {
|
|
64
|
+
throw new WorkspaceAdminIdempotencyKeyReuseError();
|
|
65
|
+
}
|
|
66
|
+
return result.result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function storeCommandResult(
|
|
70
|
+
tx: Tx,
|
|
71
|
+
params: AuditedWorkspaceCommandParams,
|
|
72
|
+
result: WorkspaceAdministrationResult,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
await tx.insert(workspacesAdminCommandResults).values({
|
|
75
|
+
actorId: params.actorId,
|
|
76
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
77
|
+
command: params.event.command,
|
|
78
|
+
requestFingerprint: params.requestFingerprint,
|
|
79
|
+
result,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function writeAdministrationAction(tx: Tx, event: AdministrationActionEvent): Promise<void> {
|
|
84
|
+
await writeOutboxEvent<AdministrationActionEventMap>(tx, workspacesOutbox, {
|
|
85
|
+
type: 'administration.action.performed',
|
|
86
|
+
payload: event,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function lockAdminCommand(
|
|
91
|
+
tx: Tx,
|
|
92
|
+
params: {actorId: string; idempotencyKeyFingerprint: string},
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
await tx.execute(
|
|
95
|
+
sql`select pg_advisory_xact_lock(hashtext(${`workspaces_admin_command:${params.actorId}:${params.idempotencyKeyFingerprint}`}))`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function updateWorkspaceStatusWithAudit(
|
|
100
|
+
params: AuditedWorkspaceCommandParams & {
|
|
101
|
+
expectedStatus: 'active' | 'suspended';
|
|
102
|
+
nextStatus: 'active' | 'suspended';
|
|
103
|
+
},
|
|
104
|
+
): Promise<WorkspaceAdministrationResult> {
|
|
105
|
+
return await db().transaction(async (tx) => {
|
|
106
|
+
await lockAdminCommand(tx, params);
|
|
107
|
+
|
|
108
|
+
const existing = await loadCommandResult(tx, {
|
|
109
|
+
actorId: params.actorId,
|
|
110
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
111
|
+
command: params.event.command,
|
|
112
|
+
requestFingerprint: params.requestFingerprint,
|
|
113
|
+
});
|
|
114
|
+
if (existing) return existing;
|
|
115
|
+
|
|
116
|
+
const rows = await tx
|
|
117
|
+
.select()
|
|
118
|
+
.from(workspaces)
|
|
119
|
+
.where(eq(workspaces.id, params.workspaceId))
|
|
120
|
+
.for('update')
|
|
121
|
+
.limit(1);
|
|
122
|
+
const workspaceRow = rows[0];
|
|
123
|
+
if (!workspaceRow) throw new WorkspaceNotFoundError(params.workspaceId);
|
|
124
|
+
if (workspaceRow.status === 'deleted') throw new WorkspaceDeletedError(params.workspaceId);
|
|
125
|
+
if (workspaceRow.status !== params.expectedStatus) {
|
|
126
|
+
if (params.nextStatus === 'suspended') {
|
|
127
|
+
throw new WorkspaceAlreadySuspendedError(params.workspaceId);
|
|
128
|
+
}
|
|
129
|
+
throw new WorkspaceNotSuspendedError(params.workspaceId);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const updatedRows = await tx
|
|
133
|
+
.update(workspaces)
|
|
134
|
+
.set({status: params.nextStatus, updatedAt: new Date()})
|
|
135
|
+
.where(eq(workspaces.id, params.workspaceId))
|
|
136
|
+
.returning();
|
|
137
|
+
const updatedRow = updatedRows[0];
|
|
138
|
+
if (!updatedRow) throw new WorkspaceNotFoundError(params.workspaceId);
|
|
139
|
+
|
|
140
|
+
const result = {
|
|
141
|
+
workspaceId: updatedRow.id,
|
|
142
|
+
status: updatedRow.status as 'active' | 'suspended',
|
|
143
|
+
correlationId: params.event.correlationId,
|
|
144
|
+
} satisfies WorkspaceAdministrationResult;
|
|
145
|
+
await writeAdministrationAction(tx, params.event);
|
|
146
|
+
await storeCommandResult(tx, params, result);
|
|
147
|
+
return result;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function suspendWorkspaceWithAudit(
|
|
152
|
+
params: AuditedWorkspaceCommandParams,
|
|
153
|
+
): Promise<WorkspaceAdministrationResult> {
|
|
154
|
+
return await updateWorkspaceStatusWithAudit({
|
|
155
|
+
...params,
|
|
156
|
+
expectedStatus: 'active',
|
|
157
|
+
nextStatus: 'suspended',
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function reactivateWorkspaceWithAudit(
|
|
162
|
+
params: AuditedWorkspaceCommandParams,
|
|
163
|
+
): Promise<WorkspaceAdministrationResult> {
|
|
164
|
+
return await updateWorkspaceStatusWithAudit({
|
|
165
|
+
...params,
|
|
166
|
+
expectedStatus: 'suspended',
|
|
167
|
+
nextStatus: 'active',
|
|
168
|
+
});
|
|
169
|
+
}
|
package/src/db/db.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
|
|
2
2
|
import {pgClient} from '@shipfox/node-postgres';
|
|
3
|
+
import {workspacesAdminCommandResults} from './schema/admin-command-results.js';
|
|
3
4
|
import {invitations} from './schema/invitations.js';
|
|
4
5
|
import {memberships} from './schema/memberships.js';
|
|
5
6
|
import {workspacesOutbox} from './schema/outbox.js';
|
|
6
7
|
import {workspaces} from './schema/workspaces.js';
|
|
7
8
|
|
|
8
9
|
export const schema = {
|
|
10
|
+
workspacesAdminCommandResults,
|
|
9
11
|
workspaces,
|
|
10
12
|
memberships,
|
|
11
13
|
invitations,
|
package/src/db/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ export {
|
|
|
22
22
|
listMembershipsByWorkspace,
|
|
23
23
|
removeMembership,
|
|
24
24
|
} from './memberships.js';
|
|
25
|
+
export {workspacesAdminCommandResults} from './schema/admin-command-results.js';
|
|
25
26
|
export {workspacesOutbox} from './schema/outbox.js';
|
|
26
27
|
export type {
|
|
27
28
|
AdminWorkspaceRow,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
|
+
import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
|
|
3
|
+
import {pgTable} from './common.js';
|
|
4
|
+
|
|
5
|
+
export interface StoredWorkspaceAdministrationResult {
|
|
6
|
+
workspaceId: string;
|
|
7
|
+
status: 'active' | 'suspended';
|
|
8
|
+
correlationId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const workspacesAdminCommandResults = pgTable(
|
|
12
|
+
'admin_command_results',
|
|
13
|
+
{
|
|
14
|
+
id: uuidv7PrimaryKey(),
|
|
15
|
+
actorId: uuid('actor_id').notNull(),
|
|
16
|
+
idempotencyKeyFingerprint: text('idempotency_key_fingerprint').notNull(),
|
|
17
|
+
command: text('command').notNull(),
|
|
18
|
+
requestFingerprint: text('request_fingerprint').notNull(),
|
|
19
|
+
result: jsonb('result').$type<StoredWorkspaceAdministrationResult>().notNull(),
|
|
20
|
+
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
21
|
+
},
|
|
22
|
+
(table) => [
|
|
23
|
+
uniqueIndex('workspaces_admin_command_results_actor_key_unique').on(
|
|
24
|
+
table.actorId,
|
|
25
|
+
table.idempotencyKeyFingerprint,
|
|
26
|
+
),
|
|
27
|
+
],
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export type WorkspaceAdminCommandResultDb = typeof workspacesAdminCommandResults.$inferSelect;
|
package/src/index.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
|
|
1
2
|
import {
|
|
2
3
|
WORKSPACES_INVITATION_SEND_REQUESTED,
|
|
3
4
|
WORKSPACES_MEMBER_INVITED,
|
|
@@ -22,12 +23,13 @@ describe('workspacesModule', () => {
|
|
|
22
23
|
const publisher = workspacesModule.publishers?.find((pub) => pub.name === 'workspaces');
|
|
23
24
|
const events = workspacesModule.subscribers?.map((subscriber) => subscriber.event);
|
|
24
25
|
|
|
25
|
-
expect(publisher?.eventSchemas).toBe(workspacesEventSchemas);
|
|
26
|
+
expect(publisher?.eventSchemas).not.toBe(workspacesEventSchemas);
|
|
26
27
|
expect(Object.keys(publisher?.eventSchemas ?? {})).toEqual([
|
|
27
28
|
WORKSPACES_INVITATION_SEND_REQUESTED,
|
|
28
29
|
WORKSPACES_WORKSPACE_CREATED,
|
|
29
30
|
WORKSPACES_MEMBER_INVITED,
|
|
30
31
|
WORKSPACES_MEMBER_JOINED,
|
|
32
|
+
ADMINISTRATION_ACTION_PERFORMED,
|
|
31
33
|
]);
|
|
32
34
|
expect(events).toContain(WORKSPACES_INVITATION_SEND_REQUESTED);
|
|
33
35
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {AuthInterModuleClient} from '@shipfox/api-auth-dto/inter-module';
|
|
2
|
+
import {administrationActionEventSchemas} from '@shipfox/api-common-dto';
|
|
2
3
|
import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
|
|
3
4
|
import type {RunnersInterModuleClient} from '@shipfox/api-runners-dto/inter-module';
|
|
4
5
|
import {
|
|
@@ -38,6 +39,10 @@ export {listMembershipsByUser} from '#db/memberships.js';
|
|
|
38
39
|
export {createWorkspacesRoutes, workspacesRoutes as routes} from '#presentation/routes/index.js';
|
|
39
40
|
|
|
40
41
|
const subscriber = subscriberFactory<WorkspacesEventMap>();
|
|
42
|
+
const workspacesPublisherEventSchemas = {
|
|
43
|
+
...workspacesEventSchemas,
|
|
44
|
+
...administrationActionEventSchemas,
|
|
45
|
+
};
|
|
41
46
|
|
|
42
47
|
export interface WorkspacesModuleOptions {
|
|
43
48
|
auth: AuthInterModuleClient;
|
|
@@ -52,7 +57,12 @@ export function createWorkspacesModule(options: WorkspacesModuleOptions): Shipfo
|
|
|
52
57
|
routes: createWorkspacesRoutes(options),
|
|
53
58
|
e2eRoutes: [workspacesE2eRoutes],
|
|
54
59
|
publishers: [
|
|
55
|
-
{
|
|
60
|
+
{
|
|
61
|
+
name: 'workspaces',
|
|
62
|
+
table: workspacesOutbox,
|
|
63
|
+
db,
|
|
64
|
+
eventSchemas: workspacesPublisherEventSchemas,
|
|
65
|
+
},
|
|
56
66
|
],
|
|
57
67
|
subscribers: [subscriber(WORKSPACES_INVITATION_SEND_REQUESTED, onInvitationSendRequested)],
|
|
58
68
|
metrics: registerWorkspacesServiceMetrics,
|
|
@@ -67,7 +77,12 @@ export const workspacesModule: ShipfoxModule = {
|
|
|
67
77
|
routes: workspacesRoutes,
|
|
68
78
|
e2eRoutes: [workspacesE2eRoutes],
|
|
69
79
|
publishers: [
|
|
70
|
-
{
|
|
80
|
+
{
|
|
81
|
+
name: 'workspaces',
|
|
82
|
+
table: workspacesOutbox,
|
|
83
|
+
db,
|
|
84
|
+
eventSchemas: workspacesPublisherEventSchemas,
|
|
85
|
+
},
|
|
71
86
|
],
|
|
72
87
|
subscribers: [subscriber(WORKSPACES_INVITATION_SEND_REQUESTED, onInvitationSendRequested)],
|
|
73
88
|
metrics: registerWorkspacesServiceMetrics,
|