@shipfox/api-workspaces 10.0.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.
Files changed (56) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +29 -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/db/admin-workspace-commands.d.ts +17 -0
  17. package/dist/db/admin-workspace-commands.d.ts.map +1 -0
  18. package/dist/db/admin-workspace-commands.js +86 -0
  19. package/dist/db/admin-workspace-commands.js.map +1 -0
  20. package/dist/db/db.d.ts +256 -0
  21. package/dist/db/db.d.ts.map +1 -1
  22. package/dist/db/db.js +2 -0
  23. package/dist/db/db.js.map +1 -1
  24. package/dist/db/index.d.ts +1 -0
  25. package/dist/db/index.d.ts.map +1 -1
  26. package/dist/db/index.js +1 -0
  27. package/dist/db/index.js.map +1 -1
  28. package/dist/db/schema/admin-command-results.d.ts +135 -0
  29. package/dist/db/schema/admin-command-results.d.ts.map +1 -0
  30. package/dist/db/schema/admin-command-results.js +18 -0
  31. package/dist/db/schema/admin-command-results.js.map +1 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +7 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/presentation/routes/admin-workspaces.d.ts.map +1 -1
  36. package/dist/presentation/routes/admin-workspaces.js +119 -14
  37. package/dist/presentation/routes/admin-workspaces.js.map +1 -1
  38. package/dist/tsconfig.test.tsbuildinfo +1 -1
  39. package/drizzle/0002_wide_enchantress.sql +11 -0
  40. package/drizzle/meta/0002_snapshot.json +555 -0
  41. package/drizzle/meta/_journal.json +7 -0
  42. package/package.json +6 -6
  43. package/src/core/admin-workspaces.ts +102 -0
  44. package/src/core/errors.ts +28 -0
  45. package/src/core/index.ts +8 -0
  46. package/src/db/admin-workspace-commands.ts +169 -0
  47. package/src/db/db.ts +2 -0
  48. package/src/db/index.ts +1 -0
  49. package/src/db/schema/admin-command-results.ts +30 -0
  50. package/src/index.test.ts +3 -1
  51. package/src/index.ts +17 -2
  52. package/src/presentation/routes/admin-workspaces.test.ts +273 -3
  53. package/src/presentation/routes/admin-workspaces.ts +143 -13
  54. package/test/globalSetup.ts +3 -1
  55. package/tsconfig.build.tsbuildinfo +1 -1
  56. 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;
@@ -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,
@@ -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
- {name: 'workspaces', table: workspacesOutbox, db, eventSchemas: workspacesEventSchemas},
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
- {name: 'workspaces', table: workspacesOutbox, db, eventSchemas: workspacesEventSchemas},
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,