@shipfox/api-workspaces 10.2.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 (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/dist/core/invitations.d.ts.map +1 -1
  4. package/dist/core/invitations.js +6 -3
  5. package/dist/core/invitations.js.map +1 -1
  6. package/dist/core/workspaces.d.ts +1 -0
  7. package/dist/core/workspaces.d.ts.map +1 -1
  8. package/dist/core/workspaces.js +5 -3
  9. package/dist/core/workspaces.js.map +1 -1
  10. package/dist/presentation/inter-module.d.ts.map +1 -1
  11. package/dist/presentation/inter-module.js +3 -2
  12. package/dist/presentation/inter-module.js.map +1 -1
  13. package/dist/presentation/routes/invitations/create.d.ts.map +1 -1
  14. package/dist/presentation/routes/invitations/create.js +5 -1
  15. package/dist/presentation/routes/invitations/create.js.map +1 -1
  16. package/dist/presentation/routes/invitations/list.d.ts.map +1 -1
  17. package/dist/presentation/routes/invitations/list.js +5 -1
  18. package/dist/presentation/routes/invitations/list.js.map +1 -1
  19. package/dist/presentation/routes/invitations/revoke.d.ts.map +1 -1
  20. package/dist/presentation/routes/invitations/revoke.js +5 -1
  21. package/dist/presentation/routes/invitations/revoke.js.map +1 -1
  22. package/dist/presentation/routes/members/list.d.ts.map +1 -1
  23. package/dist/presentation/routes/members/list.js +5 -1
  24. package/dist/presentation/routes/members/list.js.map +1 -1
  25. package/dist/presentation/routes/members/remove.d.ts.map +1 -1
  26. package/dist/presentation/routes/members/remove.js +5 -1
  27. package/dist/presentation/routes/members/remove.js.map +1 -1
  28. package/dist/tsconfig.test.tsbuildinfo +1 -1
  29. package/package.json +4 -4
  30. package/src/core/invitations.test.ts +8 -8
  31. package/src/core/invitations.ts +3 -0
  32. package/src/core/workspaces.test.ts +22 -3
  33. package/src/core/workspaces.ts +4 -1
  34. package/src/presentation/inter-module.test.ts +51 -0
  35. package/src/presentation/inter-module.ts +7 -4
  36. package/src/presentation/routes/invitations/create.test.ts +15 -0
  37. package/src/presentation/routes/invitations/create.ts +2 -1
  38. package/src/presentation/routes/invitations/list.test.ts +30 -0
  39. package/src/presentation/routes/invitations/list.ts +2 -1
  40. package/src/presentation/routes/invitations/revoke.test.ts +14 -0
  41. package/src/presentation/routes/invitations/revoke.ts +2 -1
  42. package/src/presentation/routes/members/list.test.ts +29 -1
  43. package/src/presentation/routes/members/list.ts +2 -1
  44. package/src/presentation/routes/members/remove.test.ts +14 -0
  45. package/src/presentation/routes/members/remove.ts +2 -1
  46. package/test/routes.ts +8 -3
  47. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-workspaces",
3
3
  "license": "MIT",
4
- "version": "10.2.0",
4
+ "version": "11.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -25,11 +25,11 @@
25
25
  "drizzle-orm": "^0.45.2",
26
26
  "zod": "^4.4.3",
27
27
  "@shipfox/api-common-dto": "9.2.0",
28
+ "@shipfox/api-auth-context": "11.0.0",
28
29
  "@shipfox/api-auth-dto": "10.2.0",
29
- "@shipfox/api-auth-context": "10.2.0",
30
30
  "@shipfox/api-projects-dto": "10.1.0",
31
31
  "@shipfox/api-runners-dto": "10.2.0",
32
- "@shipfox/api-workspaces-dto": "10.2.0",
32
+ "@shipfox/api-workspaces-dto": "11.0.0",
33
33
  "@shipfox/config": "1.2.4",
34
34
  "@shipfox/inter-module": "0.2.2",
35
35
  "@shipfox/node-drizzle": "0.3.4",
@@ -38,8 +38,8 @@
38
38
  "@shipfox/node-mailer": "0.2.4",
39
39
  "@shipfox/node-module": "1.0.4",
40
40
  "@shipfox/node-opentelemetry": "0.6.3",
41
- "@shipfox/node-outbox": "0.2.6",
42
41
  "@shipfox/node-postgres": "0.4.4",
42
+ "@shipfox/node-outbox": "0.2.6",
43
43
  "@shipfox/node-tokens": "0.3.2"
44
44
  },
45
45
  "imports": {
@@ -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);
@@ -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);
@@ -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) {
@@ -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 {
@@ -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,
@@ -82,6 +82,20 @@ describe('DELETE /workspaces/:workspaceId/invitations/:invitationId', () => {
82
82
  expect(res.json().code).toBe('forbidden');
83
83
  });
84
84
 
85
+ test('returns workspace-suspended for a suspended membership claim', async () => {
86
+ const workspaceId = crypto.randomUUID();
87
+ const token = `claim:${crypto.randomUUID()}:suspended-invite-revoke@example.com:${workspaceId}:suspended`;
88
+
89
+ const res = await app.inject({
90
+ method: 'DELETE',
91
+ url: `/workspaces/${workspaceId}/invitations/${crypto.randomUUID()}`,
92
+ headers: {authorization: `Bearer ${token}`},
93
+ });
94
+
95
+ expect(res.statusCode).toBe(409);
96
+ expect(res.json().code).toBe('workspace-suspended');
97
+ });
98
+
85
99
  test('transforms missing membership into 403', async () => {
86
100
  const outsider = await signupVerifyLogin(app, 'invite-revoke-outsider');
87
101
  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 {ClientError, defineRoute} from '@shipfox/node-fastify';
3
3
  import {z} from 'zod';
4
4
  import {
@@ -51,6 +51,7 @@ export const revokeInvitationRoute = defineRoute({
51
51
  }
52
52
 
53
53
  const {workspaceId, invitationId} = request.params;
54
+ requireWorkspaceAccess({request, workspaceId});
54
55
 
55
56
  await revokeWorkspaceInvitation({
56
57
  workspaceId,
@@ -1,5 +1,5 @@
1
1
  import type {FastifyInstance} from 'fastify';
2
- import {createWorkspace as createWorkspaceRow} from '#db/workspaces.js';
2
+ import {createWorkspace as createWorkspaceRow, updateWorkspace} from '#db/workspaces.js';
3
3
  import {
4
4
  createInvite,
5
5
  createWorkspace,
@@ -50,6 +50,20 @@ describe('GET /workspaces/:workspaceId/members', () => {
50
50
  ).toEqual([guest.email, owner.email].sort());
51
51
  });
52
52
 
53
+ test('keeps an active claim usable when the workspace row is suspended', async () => {
54
+ const owner = await signupVerifyLogin(app, 'members-list-snapshot');
55
+ const workspaceId = await createWorkspace(app, owner.token);
56
+ await updateWorkspace({id: workspaceId, status: 'suspended'});
57
+
58
+ const res = await app.inject({
59
+ method: 'GET',
60
+ url: `/workspaces/${workspaceId}/members`,
61
+ headers: {authorization: `Bearer ${owner.token}`},
62
+ });
63
+
64
+ expect(res.statusCode).toBe(200);
65
+ });
66
+
53
67
  test('authorizes from a static membership claim without a membership row', async () => {
54
68
  const userId = crypto.randomUUID();
55
69
  const email = `members-list-claim-${crypto.randomUUID()}@example.com`;
@@ -66,6 +80,20 @@ describe('GET /workspaces/:workspaceId/members', () => {
66
80
  expect(res.json().members).toEqual([]);
67
81
  });
68
82
 
83
+ test('returns workspace-suspended for a suspended membership claim', async () => {
84
+ const workspaceId = crypto.randomUUID();
85
+ const token = `claim:${crypto.randomUUID()}:suspended-list@example.com:${workspaceId}:suspended`;
86
+
87
+ const res = await app.inject({
88
+ method: 'GET',
89
+ url: `/workspaces/${workspaceId}/members`,
90
+ headers: {authorization: `Bearer ${token}`},
91
+ });
92
+
93
+ expect(res.statusCode).toBe(409);
94
+ expect(res.json().code).toBe('workspace-suspended');
95
+ });
96
+
69
97
  test('transforms missing membership into 403', async () => {
70
98
  const outsider = await signupVerifyLogin(app, 'members-list-outsider');
71
99
  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 {listMembersResponseSchema} 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 listMembersRoute = defineRoute({
40
40
  }
41
41
 
42
42
  const {workspaceId} = request.params;
43
+ requireWorkspaceAccess({request, workspaceId});
43
44
  const members = await listWorkspaceMembers({
44
45
  workspaceId,
45
46
  requesterUserId: client.userId,
@@ -79,6 +79,20 @@ describe('DELETE /workspaces/:workspaceId/members/:userId', () => {
79
79
  expect(res.json().code).toBe('self-removal-not-allowed');
80
80
  });
81
81
 
82
+ test('returns workspace-suspended for a suspended membership claim', async () => {
83
+ const workspaceId = crypto.randomUUID();
84
+ const token = `claim:${crypto.randomUUID()}:suspended-member-remove@example.com:${workspaceId}:suspended`;
85
+
86
+ const res = await app.inject({
87
+ method: 'DELETE',
88
+ url: `/workspaces/${workspaceId}/members/${crypto.randomUUID()}`,
89
+ headers: {authorization: `Bearer ${token}`},
90
+ });
91
+
92
+ expect(res.statusCode).toBe(409);
93
+ expect(res.json().code).toBe('workspace-suspended');
94
+ });
95
+
82
96
  test('transforms missing membership into 403', async () => {
83
97
  const outsider = await signupVerifyLogin(app, 'members-remove-outsider');
84
98
  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 {ClientError, defineRoute} from '@shipfox/node-fastify';
3
3
  import {z} from 'zod';
4
4
  import {
@@ -53,6 +53,7 @@ export const removeMemberRoute = defineRoute({
53
53
  }
54
54
 
55
55
  const {workspaceId, userId} = request.params;
56
+ requireWorkspaceAccess({request, workspaceId});
56
57
 
57
58
  await removeWorkspaceMember({
58
59
  workspaceId,
package/test/routes.ts CHANGED
@@ -52,14 +52,15 @@ const fakeUserAuth: AuthMethod = {
52
52
  authenticate: async (request) => {
53
53
  const raw = request.headers.authorization?.replace(BEARER_RE, '');
54
54
  if (raw?.startsWith('claim:')) {
55
- const [, userId, email, workspaceId] = raw.split(':');
55
+ const [, userId, email, workspaceId, status] = raw.split(':');
56
56
  if (!userId || !email || !workspaceId) throw new Error('Invalid test user claim token');
57
+ const workspaceStatus = status === 'suspended' || status === 'deleted' ? status : 'active';
57
58
  setUserContext(
58
59
  request,
59
60
  buildUserContext({
60
61
  userId,
61
62
  email,
62
- memberships: [{workspaceId, role: 'admin'}],
63
+ memberships: [{workspaceId, role: 'admin', workspaceStatus}],
63
64
  }),
64
65
  );
65
66
  return;
@@ -74,7 +75,11 @@ const fakeUserAuth: AuthMethod = {
74
75
  userId,
75
76
  email,
76
77
  name: encodedName ? decodeURIComponent(encodedName) : null,
77
- memberships: memberships.map((m) => ({workspaceId: m.workspaceId, role: 'admin' as const})),
78
+ memberships: memberships.map((m) => ({
79
+ workspaceId: m.workspaceId,
80
+ role: 'admin' as const,
81
+ workspaceStatus: 'active' as const,
82
+ })),
78
83
  }),
79
84
  );
80
85
  },