@shipfox/api-workspaces 10.2.0 → 12.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 (167) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +27 -4
  4. package/dist/core/entities/workspace.d.ts +1 -0
  5. package/dist/core/entities/workspace.d.ts.map +1 -1
  6. package/dist/core/entities/workspace.js.map +1 -1
  7. package/dist/core/errors.d.ts +3 -0
  8. package/dist/core/errors.d.ts.map +1 -1
  9. package/dist/core/errors.js +6 -0
  10. package/dist/core/errors.js.map +1 -1
  11. package/dist/core/index.d.ts +2 -2
  12. package/dist/core/index.d.ts.map +1 -1
  13. package/dist/core/index.js +2 -2
  14. package/dist/core/index.js.map +1 -1
  15. package/dist/core/invitations.d.ts.map +1 -1
  16. package/dist/core/invitations.js +7 -4
  17. package/dist/core/invitations.js.map +1 -1
  18. package/dist/core/rate-limit.d.ts +39 -0
  19. package/dist/core/rate-limit.d.ts.map +1 -0
  20. package/dist/core/rate-limit.js +70 -0
  21. package/dist/core/rate-limit.js.map +1 -0
  22. package/dist/core/workspaces.d.ts +8 -0
  23. package/dist/core/workspaces.d.ts.map +1 -1
  24. package/dist/core/workspaces.js +47 -7
  25. package/dist/core/workspaces.js.map +1 -1
  26. package/dist/db/db.d.ts +320 -0
  27. package/dist/db/db.d.ts.map +1 -1
  28. package/dist/db/db.js +3 -1
  29. package/dist/db/db.js.map +1 -1
  30. package/dist/db/index.d.ts +4 -1
  31. package/dist/db/index.d.ts.map +1 -1
  32. package/dist/db/index.js +3 -1
  33. package/dist/db/index.js.map +1 -1
  34. package/dist/db/memberships.d.ts +1 -0
  35. package/dist/db/memberships.d.ts.map +1 -1
  36. package/dist/db/memberships.js +2 -0
  37. package/dist/db/memberships.js.map +1 -1
  38. package/dist/db/rate-limits.d.ts +9 -0
  39. package/dist/db/rate-limits.d.ts.map +1 -0
  40. package/dist/db/rate-limits.js +43 -0
  41. package/dist/db/rate-limits.js.map +1 -0
  42. package/dist/db/schema/rate-limits.d.ts +145 -0
  43. package/dist/db/schema/rate-limits.d.ts.map +1 -0
  44. package/dist/db/schema/rate-limits.js +25 -0
  45. package/dist/db/schema/rate-limits.js.map +1 -0
  46. package/dist/db/schema/workspaces.d.ts +17 -0
  47. package/dist/db/schema/workspaces.d.ts.map +1 -1
  48. package/dist/db/schema/workspaces.js +5 -2
  49. package/dist/db/schema/workspaces.js.map +1 -1
  50. package/dist/db/workspaces.d.ts +11 -1
  51. package/dist/db/workspaces.d.ts.map +1 -1
  52. package/dist/db/workspaces.js +12 -3
  53. package/dist/db/workspaces.js.map +1 -1
  54. package/dist/index.d.ts +1 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/metrics/instance.d.ts +6 -0
  59. package/dist/metrics/instance.d.ts.map +1 -1
  60. package/dist/metrics/instance.js +12 -0
  61. package/dist/metrics/instance.js.map +1 -1
  62. package/dist/presentation/dto/membership.d.ts.map +1 -1
  63. package/dist/presentation/dto/membership.js +1 -0
  64. package/dist/presentation/dto/membership.js.map +1 -1
  65. package/dist/presentation/dto/workspace.d.ts.map +1 -1
  66. package/dist/presentation/dto/workspace.js +1 -0
  67. package/dist/presentation/dto/workspace.js.map +1 -1
  68. package/dist/presentation/e2eRoutes/create-workspace.d.ts.map +1 -1
  69. package/dist/presentation/e2eRoutes/create-workspace.js +11 -1
  70. package/dist/presentation/e2eRoutes/create-workspace.js.map +1 -1
  71. package/dist/presentation/inter-module.d.ts.map +1 -1
  72. package/dist/presentation/inter-module.js +3 -2
  73. package/dist/presentation/inter-module.js.map +1 -1
  74. package/dist/presentation/routes/index.d.ts.map +1 -1
  75. package/dist/presentation/routes/index.js +3 -1
  76. package/dist/presentation/routes/index.js.map +1 -1
  77. package/dist/presentation/routes/invitations/create.d.ts.map +1 -1
  78. package/dist/presentation/routes/invitations/create.js +5 -1
  79. package/dist/presentation/routes/invitations/create.js.map +1 -1
  80. package/dist/presentation/routes/invitations/list.d.ts.map +1 -1
  81. package/dist/presentation/routes/invitations/list.js +5 -1
  82. package/dist/presentation/routes/invitations/list.js.map +1 -1
  83. package/dist/presentation/routes/invitations/revoke.d.ts.map +1 -1
  84. package/dist/presentation/routes/invitations/revoke.js +5 -1
  85. package/dist/presentation/routes/invitations/revoke.js.map +1 -1
  86. package/dist/presentation/routes/members/list.d.ts.map +1 -1
  87. package/dist/presentation/routes/members/list.js +5 -1
  88. package/dist/presentation/routes/members/list.js.map +1 -1
  89. package/dist/presentation/routes/members/remove.d.ts.map +1 -1
  90. package/dist/presentation/routes/members/remove.js +5 -1
  91. package/dist/presentation/routes/members/remove.js.map +1 -1
  92. package/dist/presentation/routes/rate-limit.d.ts +3 -0
  93. package/dist/presentation/routes/rate-limit.d.ts.map +1 -0
  94. package/dist/presentation/routes/rate-limit.js +42 -0
  95. package/dist/presentation/routes/rate-limit.js.map +1 -0
  96. package/dist/presentation/routes/workspaces/create.d.ts.map +1 -1
  97. package/dist/presentation/routes/workspaces/create.js +11 -1
  98. package/dist/presentation/routes/workspaces/create.js.map +1 -1
  99. package/dist/presentation/routes/workspaces/index.d.ts +2 -0
  100. package/dist/presentation/routes/workspaces/index.d.ts.map +1 -1
  101. package/dist/presentation/routes/workspaces/index.js +2 -0
  102. package/dist/presentation/routes/workspaces/index.js.map +1 -1
  103. package/dist/presentation/routes/workspaces/slug-availability.d.ts +2 -0
  104. package/dist/presentation/routes/workspaces/slug-availability.d.ts.map +1 -0
  105. package/dist/presentation/routes/workspaces/slug-availability.js +31 -0
  106. package/dist/presentation/routes/workspaces/slug-availability.js.map +1 -0
  107. package/dist/presentation/routes/workspaces/update.d.ts +2 -0
  108. package/dist/presentation/routes/workspaces/update.d.ts.map +1 -0
  109. package/dist/presentation/routes/workspaces/update.js +49 -0
  110. package/dist/presentation/routes/workspaces/update.js.map +1 -0
  111. package/dist/tsconfig.test.tsbuildinfo +1 -1
  112. package/drizzle/0000_initial.sql +2 -0
  113. package/drizzle/0003_dapper_talos.sql +13 -0
  114. package/drizzle/meta/0000_snapshot.json +23 -1
  115. package/drizzle/meta/0003_snapshot.json +689 -0
  116. package/drizzle/meta/_journal.json +7 -0
  117. package/package.json +14 -12
  118. package/src/core/entities/workspace.ts +1 -0
  119. package/src/core/errors.ts +7 -0
  120. package/src/core/index.ts +3 -0
  121. package/src/core/invitations.test.ts +8 -8
  122. package/src/core/invitations.ts +4 -1
  123. package/src/core/rate-limit.test.ts +113 -0
  124. package/src/core/rate-limit.ts +119 -0
  125. package/src/core/workspaces.test.ts +31 -3
  126. package/src/core/workspaces.ts +51 -4
  127. package/src/db/db.ts +2 -0
  128. package/src/db/index.ts +10 -0
  129. package/src/db/memberships.ts +3 -0
  130. package/src/db/rate-limits.test.ts +74 -0
  131. package/src/db/rate-limits.ts +59 -0
  132. package/src/db/schema/rate-limits.ts +27 -0
  133. package/src/db/schema/workspaces.ts +7 -2
  134. package/src/db/workspaces.ts +21 -1
  135. package/src/index.test.ts +2 -0
  136. package/src/index.ts +1 -0
  137. package/src/metrics/instance.ts +26 -0
  138. package/src/presentation/dto/membership.ts +1 -0
  139. package/src/presentation/dto/workspace.ts +1 -0
  140. package/src/presentation/e2eRoutes/create-workspace.ts +9 -1
  141. package/src/presentation/inter-module.test.ts +69 -0
  142. package/src/presentation/inter-module.ts +7 -4
  143. package/src/presentation/routes/index.ts +8 -1
  144. package/src/presentation/routes/invitations/create.test.ts +15 -0
  145. package/src/presentation/routes/invitations/create.ts +2 -1
  146. package/src/presentation/routes/invitations/list.test.ts +30 -0
  147. package/src/presentation/routes/invitations/list.ts +2 -1
  148. package/src/presentation/routes/invitations/revoke.test.ts +14 -0
  149. package/src/presentation/routes/invitations/revoke.ts +2 -1
  150. package/src/presentation/routes/members/list.test.ts +29 -1
  151. package/src/presentation/routes/members/list.ts +2 -1
  152. package/src/presentation/routes/members/remove.test.ts +14 -0
  153. package/src/presentation/routes/members/remove.ts +2 -1
  154. package/src/presentation/routes/rate-limit.ts +45 -0
  155. package/src/presentation/routes/workspaces/create.test.ts +33 -3
  156. package/src/presentation/routes/workspaces/create.ts +9 -1
  157. package/src/presentation/routes/workspaces/index.ts +2 -0
  158. package/src/presentation/routes/workspaces/list.test.ts +1 -0
  159. package/src/presentation/routes/workspaces/slug-availability.test.ts +159 -0
  160. package/src/presentation/routes/workspaces/slug-availability.ts +30 -0
  161. package/src/presentation/routes/workspaces/update.test.ts +114 -0
  162. package/src/presentation/routes/workspaces/update.ts +41 -0
  163. package/test/env.ts +1 -0
  164. package/test/factories/workspace.ts +3 -1
  165. package/test/globalSetup.ts +1 -1
  166. package/test/routes.ts +26 -7
  167. 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": "12.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -24,22 +24,24 @@
24
24
  "dependencies": {
25
25
  "drizzle-orm": "^0.45.2",
26
26
  "zod": "^4.4.3",
27
- "@shipfox/api-common-dto": "9.2.0",
28
- "@shipfox/api-auth-dto": "10.2.0",
29
- "@shipfox/api-auth-context": "10.2.0",
30
- "@shipfox/api-projects-dto": "10.1.0",
31
- "@shipfox/api-runners-dto": "10.2.0",
32
- "@shipfox/api-workspaces-dto": "10.2.0",
27
+ "@shipfox/api-common-dto": "12.0.0",
28
+ "@shipfox/api-auth-context": "12.0.0",
29
+ "@shipfox/api-auth-dto": "12.0.0",
30
+ "@shipfox/api-projects-dto": "12.0.0",
31
+ "@shipfox/api-runners-dto": "12.0.0",
32
+ "@shipfox/api-workspaces-dto": "12.0.0",
33
33
  "@shipfox/config": "1.2.4",
34
- "@shipfox/inter-module": "0.2.2",
35
- "@shipfox/node-drizzle": "0.3.4",
36
- "@shipfox/node-fastify": "0.4.0",
34
+ "@shipfox/inter-module": "0.2.3",
35
+ "@shipfox/node-auth-root-key": "0.2.3",
36
+ "@shipfox/node-drizzle": "0.3.5",
37
+ "@shipfox/node-fastify": "0.4.1",
37
38
  "@shipfox/node-email": "0.3.4",
38
39
  "@shipfox/node-mailer": "0.2.4",
39
- "@shipfox/node-module": "1.0.4",
40
+ "@shipfox/node-module": "1.0.5",
40
41
  "@shipfox/node-opentelemetry": "0.6.3",
41
42
  "@shipfox/node-outbox": "0.2.6",
42
- "@shipfox/node-postgres": "0.4.4",
43
+ "@shipfox/node-postgres": "0.5.0",
44
+ "@shipfox/node-rate-limit": "0.4.0",
43
45
  "@shipfox/node-tokens": "0.3.2"
44
46
  },
45
47
  "imports": {
@@ -3,6 +3,7 @@ export type WorkspaceStatus = 'active' | 'suspended' | 'deleted';
3
3
  export interface Workspace {
4
4
  id: string;
5
5
  name: string;
6
+ slug: string;
6
7
  status: WorkspaceStatus;
7
8
  settings: Record<string, unknown>;
8
9
  createdBy: string | null;
@@ -5,6 +5,13 @@ export class WorkspaceNotFoundError extends Error {
5
5
  }
6
6
  }
7
7
 
8
+ export class WorkspaceSlugConflictError extends Error {
9
+ constructor(slug: string) {
10
+ super(`Workspace slug is already taken: ${slug}`);
11
+ this.name = 'WorkspaceSlugConflictError';
12
+ }
13
+ }
14
+
8
15
  export class WorkspaceInactiveError extends Error {
9
16
  constructor(workspaceId: string) {
10
17
  super(`Workspace is not active: ${workspaceId}`);
package/src/core/index.ts CHANGED
@@ -27,6 +27,7 @@ export {
27
27
  WorkspaceDeletedError,
28
28
  WorkspaceNotFoundError,
29
29
  WorkspaceNotSuspendedError,
30
+ WorkspaceSlugConflictError,
30
31
  } from './errors.js';
31
32
  export {
32
33
  acceptWorkspaceInvitation,
@@ -38,6 +39,7 @@ export {
38
39
  } from './invitations.js';
39
40
  export {type EnsureMembershipParams, ensureMembership} from './memberships.js';
40
41
  export {
42
+ checkWorkspaceSlugAvailability,
41
43
  createWorkspaceForUser,
42
44
  getWorkspace,
43
45
  listUserWorkspaceMemberships,
@@ -46,4 +48,5 @@ export {
46
48
  type RequireWorkspaceMembershipResult,
47
49
  removeWorkspaceMember,
48
50
  requireWorkspaceMembership,
51
+ updateWorkspaceDetails,
49
52
  } from './workspaces.js';
@@ -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');
@@ -99,7 +100,7 @@ export async function previewInvitation(params: {token: string}): Promise<Previe
99
100
 
100
101
  const workspace = await getWorkspaceById(invitation.workspaceId);
101
102
  if (!workspace) {
102
- // Workspace cascade-deleted the invitation row is orphaned. Treat as invalid.
103
+ // Workspace cascade-deleted: the invitation row is orphaned. Treat as invalid.
103
104
  return {status: 'invalid'};
104
105
  }
105
106
 
@@ -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});
@@ -0,0 +1,113 @@
1
+ import {eq, sql} from 'drizzle-orm';
2
+ import {db} from '#db/db.js';
3
+ import {workspacesRateLimits} from '#db/schema/rate-limits.js';
4
+ import {
5
+ checkWorkspacesRateLimit,
6
+ hashWorkspacesRateLimitIdentifier,
7
+ WorkspacesRateLimitUnavailableError,
8
+ } from './rate-limit.js';
9
+
10
+ const HMAC_HEX_PATTERN = /^[a-f0-9]{64}$/;
11
+
12
+ describe('checkWorkspacesRateLimit', () => {
13
+ it('hashes identifiers without storing the raw identifier', async () => {
14
+ const identifier = `ip-${crypto.randomUUID()}`;
15
+ const identifierHmac = hashWorkspacesRateLimitIdentifier({
16
+ action: 'slug-availability',
17
+ scope: 'ip',
18
+ identifier,
19
+ });
20
+
21
+ await checkWorkspacesRateLimit({
22
+ action: 'slug-availability',
23
+ scope: 'ip',
24
+ identifier,
25
+ limit: 1,
26
+ windowSeconds: 60,
27
+ now: new Date('2026-06-23T00:00:10Z'),
28
+ });
29
+
30
+ const rows = await db()
31
+ .select({identifierHmac: workspacesRateLimits.identifierHmac})
32
+ .from(workspacesRateLimits)
33
+ .where(eq(workspacesRateLimits.identifierHmac, identifierHmac));
34
+
35
+ expect(identifierHmac).toMatch(HMAC_HEX_PATTERN);
36
+ expect(identifierHmac).not.toContain(identifier);
37
+ expect(rows).toEqual([{identifierHmac}]);
38
+ });
39
+
40
+ it('rejects the first over-limit attempt with retry-after seconds', async () => {
41
+ const identifier = `ip-${crypto.randomUUID()}`;
42
+ const now = new Date('2026-06-23T00:00:10Z');
43
+
44
+ await checkWorkspacesRateLimit({
45
+ action: 'slug-availability',
46
+ scope: 'ip',
47
+ identifier,
48
+ limit: 2,
49
+ windowSeconds: 60,
50
+ now,
51
+ });
52
+ await checkWorkspacesRateLimit({
53
+ action: 'slug-availability',
54
+ scope: 'ip',
55
+ identifier,
56
+ limit: 2,
57
+ windowSeconds: 60,
58
+ now,
59
+ });
60
+ const result = checkWorkspacesRateLimit({
61
+ action: 'slug-availability',
62
+ scope: 'ip',
63
+ identifier,
64
+ limit: 2,
65
+ windowSeconds: 60,
66
+ now,
67
+ });
68
+
69
+ await expect(result).rejects.toMatchObject({
70
+ name: 'WorkspacesRateLimitExceededError',
71
+ retryAfterSeconds: 50,
72
+ });
73
+ });
74
+
75
+ it('fails closed when the limiter query times out', async () => {
76
+ const identifier = `ip-${crypto.randomUUID()}`;
77
+ const identifierHmac = hashWorkspacesRateLimitIdentifier({
78
+ action: 'slug-availability',
79
+ scope: 'ip',
80
+ identifier,
81
+ });
82
+ await db()
83
+ .insert(workspacesRateLimits)
84
+ .values({
85
+ action: 'slug-availability',
86
+ scope: 'ip',
87
+ identifierHmac,
88
+ windowStart: new Date('2026-06-23T00:03:00Z'),
89
+ count: 1,
90
+ expiresAt: new Date('2026-06-23T00:04:00Z'),
91
+ });
92
+
93
+ await db().transaction(async (tx) => {
94
+ await tx.execute(sql`
95
+ SELECT 1
96
+ FROM workspaces_rate_limits
97
+ WHERE identifier_hmac = ${identifierHmac}
98
+ FOR UPDATE
99
+ `);
100
+ const result = checkWorkspacesRateLimit({
101
+ action: 'slug-availability',
102
+ scope: 'ip',
103
+ identifier,
104
+ limit: 1,
105
+ windowSeconds: 60,
106
+ now: new Date('2026-06-23T00:03:30Z'),
107
+ timeoutMs: 10,
108
+ });
109
+
110
+ await expect(result).rejects.toBeInstanceOf(WorkspacesRateLimitUnavailableError);
111
+ });
112
+ });
113
+ });
@@ -0,0 +1,119 @@
1
+ import {rateLimitIdentifierKey} from '@shipfox/node-auth-root-key';
2
+ import {
3
+ checkRateLimit,
4
+ hashRateLimitIdentifier,
5
+ RateLimitExceededError,
6
+ type RateLimitOutcome,
7
+ type RateLimitPolicy,
8
+ RateLimitUnavailableError,
9
+ } from '@shipfox/node-rate-limit';
10
+ import {consumeWorkspacesRateLimit, pruneExpiredWorkspacesRateLimits} from '#db/rate-limits.js';
11
+ import {
12
+ recordWorkspaceRateLimitCheck,
13
+ recordWorkspaceRateLimitPruneFailure,
14
+ } from '#metrics/instance.js';
15
+
16
+ export type WorkspacesRateLimitAction = 'slug-availability';
17
+ export type WorkspacesRateLimitScope = 'ip';
18
+ export type WorkspacesRateLimitOutcome = RateLimitOutcome;
19
+ export type WorkspacesRateLimitPolicy = RateLimitPolicy;
20
+
21
+ export interface CheckWorkspacesRateLimitParams extends WorkspacesRateLimitPolicy {
22
+ action: WorkspacesRateLimitAction;
23
+ scope: WorkspacesRateLimitScope;
24
+ identifier: string;
25
+ now?: Date | undefined;
26
+ timeoutMs?: number | undefined;
27
+ }
28
+
29
+ export class WorkspacesRateLimitExceededError extends RateLimitExceededError<
30
+ WorkspacesRateLimitAction,
31
+ WorkspacesRateLimitScope
32
+ > {
33
+ constructor(params: {
34
+ action: WorkspacesRateLimitAction;
35
+ scope: WorkspacesRateLimitScope;
36
+ retryAfterSeconds: number;
37
+ identifierHmacPrefix: string;
38
+ }) {
39
+ super(params);
40
+ this.name = 'WorkspacesRateLimitExceededError';
41
+ }
42
+ }
43
+
44
+ export class WorkspacesRateLimitUnavailableError extends RateLimitUnavailableError<
45
+ WorkspacesRateLimitAction,
46
+ WorkspacesRateLimitScope
47
+ > {
48
+ constructor(params: {
49
+ action: WorkspacesRateLimitAction;
50
+ scope: WorkspacesRateLimitScope;
51
+ identifierHmacPrefix: string;
52
+ cause: unknown;
53
+ }) {
54
+ super(params);
55
+ this.name = 'WorkspacesRateLimitUnavailableError';
56
+ }
57
+ }
58
+
59
+ export const WORKSPACE_SLUG_AVAILABILITY_RATE_LIMIT = {
60
+ limit: 60,
61
+ windowSeconds: 5 * 60,
62
+ } as const;
63
+
64
+ const RATE_LIMIT_TIMEOUT_MS = 250;
65
+ const IDENTIFIER_HASH_DOMAIN = 'shipfox.workspaces.rate-limit.identifier.v1';
66
+
67
+ export function hashWorkspacesRateLimitIdentifier(params: {
68
+ action: WorkspacesRateLimitAction;
69
+ scope: WorkspacesRateLimitScope;
70
+ identifier: string;
71
+ }): string {
72
+ return hashRateLimitIdentifier({
73
+ action: params.action,
74
+ scope: params.scope,
75
+ identifier: params.identifier,
76
+ secret: rateLimitIdentifierKey(),
77
+ domain: IDENTIFIER_HASH_DOMAIN,
78
+ });
79
+ }
80
+
81
+ export async function checkWorkspacesRateLimit(
82
+ params: CheckWorkspacesRateLimitParams,
83
+ ): Promise<void> {
84
+ try {
85
+ await checkRateLimit({
86
+ action: params.action,
87
+ scope: params.scope,
88
+ identifier: params.identifier,
89
+ limit: params.limit,
90
+ windowSeconds: params.windowSeconds,
91
+ identifierSecret: rateLimitIdentifierKey(),
92
+ identifierHashDomain: IDENTIFIER_HASH_DOMAIN,
93
+ consume: consumeWorkspacesRateLimit,
94
+ prune: pruneExpiredWorkspacesRateLimits,
95
+ onCheck: recordWorkspaceRateLimitCheck,
96
+ onPruneFailure: recordWorkspaceRateLimitPruneFailure,
97
+ now: params.now,
98
+ timeoutMs: params.timeoutMs ?? RATE_LIMIT_TIMEOUT_MS,
99
+ });
100
+ } catch (error) {
101
+ if (error instanceof RateLimitExceededError) {
102
+ throw new WorkspacesRateLimitExceededError({
103
+ action: error.action,
104
+ scope: error.scope,
105
+ retryAfterSeconds: error.retryAfterSeconds,
106
+ identifierHmacPrefix: error.identifierHmacPrefix,
107
+ });
108
+ }
109
+ if (error instanceof RateLimitUnavailableError) {
110
+ throw new WorkspacesRateLimitUnavailableError({
111
+ action: error.action,
112
+ scope: error.scope,
113
+ identifierHmacPrefix: error.identifierHmacPrefix,
114
+ cause: error.cause,
115
+ });
116
+ }
117
+ throw error;
118
+ }
119
+ }
@@ -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,
@@ -15,9 +15,11 @@ import {
15
15
  describe('workspaces core', () => {
16
16
  test('createWorkspaceForUser creates a workspace and membership for the user', async () => {
17
17
  const user = userFactory.build();
18
+ const slug = `core-${crypto.randomUUID().slice(0, 8)}`;
18
19
 
19
20
  const workspace = await createWorkspaceForUser({
20
21
  name: 'Core Workspace',
22
+ slug,
21
23
  userId: user.userId,
22
24
  userEmail: user.email,
23
25
  userName: user.name,
@@ -25,6 +27,7 @@ describe('workspaces core', () => {
25
27
  const membership = await findMembership({userId: user.userId, workspaceId: workspace.id});
26
28
 
27
29
  expect(workspace.name).toBe('Core Workspace');
30
+ expect(workspace.slug).toBe(slug);
28
31
  expect(membership).toBeDefined();
29
32
  });
30
33
 
@@ -33,6 +36,7 @@ describe('workspaces core', () => {
33
36
 
34
37
  const workspace = await createWorkspaceForUser({
35
38
  name: 'Evented Workspace',
39
+ slug: `evented-${crypto.randomUUID().slice(0, 8)}`,
36
40
  userId: user.userId,
37
41
  userEmail: user.email,
38
42
  userName: user.name,
@@ -47,6 +51,7 @@ describe('workspaces core', () => {
47
51
  payload: {
48
52
  workspaceId: workspace.id,
49
53
  name: workspace.name,
54
+ slug: workspace.slug,
50
55
  creatorUserId: user.userId,
51
56
  },
52
57
  });
@@ -56,6 +61,7 @@ describe('workspaces core', () => {
56
61
  const user = userFactory.build();
57
62
  const workspace = await createWorkspaceForUser({
58
63
  name: 'Member Workspace',
64
+ slug: `member-${crypto.randomUUID().slice(0, 8)}`,
59
65
  userId: user.userId,
60
66
  userEmail: user.email,
61
67
  userName: user.name,
@@ -64,7 +70,7 @@ describe('workspaces core', () => {
64
70
  const result = await requireWorkspaceMembership({
65
71
  workspaceId: workspace.id,
66
72
  userId: user.userId,
67
- memberships: [{workspaceId: workspace.id, role: 'admin'}],
73
+ memberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
68
74
  });
69
75
 
70
76
  expect(result.workspace.id).toBe(workspace.id);
@@ -75,6 +81,7 @@ describe('workspaces core', () => {
75
81
  test('getWorkspaceOperatingState returns the current status without workspace details', async () => {
76
82
  const workspace = await createWorkspaceForUser({
77
83
  name: 'Operating State Workspace',
84
+ slug: `operating-${crypto.randomUUID().slice(0, 8)}`,
78
85
  userId: crypto.randomUUID(),
79
86
  });
80
87
 
@@ -85,11 +92,32 @@ describe('workspaces core', () => {
85
92
  expect(await getWorkspaceOperatingState({workspaceId: workspace.id})).toBe('suspended');
86
93
  });
87
94
 
95
+ test('requireWorkspaceMembership rejects a suspended workspace', async () => {
96
+ const user = userFactory.build();
97
+ const workspace = await createWorkspaceForUser({
98
+ name: 'Suspended Workspace',
99
+ slug: `suspended-${crypto.randomUUID().slice(0, 8)}`,
100
+ userId: user.userId,
101
+ userEmail: user.email,
102
+ userName: user.name,
103
+ });
104
+ await updateWorkspace({id: workspace.id, status: 'suspended'});
105
+
106
+ const membership = requireWorkspaceMembership({
107
+ workspaceId: workspace.id,
108
+ userId: user.userId,
109
+ memberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
110
+ });
111
+
112
+ await expect(membership).rejects.toBeInstanceOf(WorkspaceInactiveError);
113
+ });
114
+
88
115
  test('requireWorkspaceMembership rejects when memberships do not include the workspace', async () => {
89
116
  const owner = userFactory.build();
90
117
  const outsider = userFactory.build();
91
118
  const workspace = await createWorkspaceForUser({
92
119
  name: 'Private Workspace',
120
+ slug: `private-${crypto.randomUUID().slice(0, 8)}`,
93
121
  userId: owner.userId,
94
122
  userEmail: owner.email,
95
123
  userName: owner.name,
@@ -110,7 +138,7 @@ describe('workspaces core', () => {
110
138
  const missingWorkspace = requireWorkspaceMembership({
111
139
  workspaceId: ghostId,
112
140
  userId: owner.userId,
113
- memberships: [{workspaceId: ghostId, role: 'admin'}],
141
+ memberships: [{workspaceId: ghostId, role: 'admin', workspaceStatus: 'active'}],
114
142
  });
115
143
 
116
144
  await expect(missingWorkspace).rejects.toBeInstanceOf(WorkspaceNotFoundError);
@@ -1,9 +1,11 @@
1
1
  import type {UserContextMembership} from '@shipfox/api-auth-context';
2
2
  import {
3
3
  WORKSPACES_WORKSPACE_CREATED,
4
+ WORKSPACES_WORKSPACE_UPDATED,
4
5
  type WorkspaceRole,
5
6
  type WorkspacesEventMap,
6
7
  } from '@shipfox/api-workspaces-dto';
8
+ import {isUniqueViolation} from '@shipfox/node-drizzle';
7
9
  import {writeOutboxEvent} from '@shipfox/node-outbox';
8
10
  import {db} from '#db/db.js';
9
11
  import {
@@ -17,7 +19,7 @@ import {
17
19
  import {memberships} from '#db/schema/memberships.js';
18
20
  import {workspacesOutbox} from '#db/schema/outbox.js';
19
21
  import {toWorkspace, workspaces} from '#db/schema/workspaces.js';
20
- import {getWorkspaceById} from '#db/workspaces.js';
22
+ import {getWorkspaceById, isWorkspaceSlugAvailable, updateWorkspace} from '#db/workspaces.js';
21
23
  import type {Workspace} from './entities/workspace.js';
22
24
  import {
23
25
  MembershipNotFoundError,
@@ -25,12 +27,14 @@ import {
25
27
  SelfRemovalNotAllowedError,
26
28
  WorkspaceInactiveError,
27
29
  WorkspaceNotFoundError,
30
+ WorkspaceSlugConflictError,
28
31
  } from './errors.js';
29
32
 
30
33
  export interface RequireWorkspaceMembershipParams {
31
34
  workspaceId: string;
32
35
  userId: string;
33
36
  memberships: ReadonlyArray<UserContextMembership>;
37
+ enforceWorkspaceStatus?: boolean;
34
38
  }
35
39
 
36
40
  export interface RequireWorkspaceMembershipResult {
@@ -52,7 +56,7 @@ export async function requireWorkspaceMembership(
52
56
  if (!workspace) {
53
57
  throw new WorkspaceNotFoundError(params.workspaceId);
54
58
  }
55
- if (workspace.status !== 'active') {
59
+ if (params.enforceWorkspaceStatus !== false && workspace.status !== 'active') {
56
60
  throw new WorkspaceInactiveError(params.workspaceId);
57
61
  }
58
62
 
@@ -61,6 +65,7 @@ export async function requireWorkspaceMembership(
61
65
 
62
66
  export async function createWorkspaceForUser(params: {
63
67
  name: string;
68
+ slug: string;
64
69
  userId: string;
65
70
  userEmail?: string | undefined;
66
71
  userName?: string | null | undefined;
@@ -68,9 +73,12 @@ export async function createWorkspaceForUser(params: {
68
73
  return await db().transaction(async (tx) => {
69
74
  const [workspaceRow] = await tx
70
75
  .insert(workspaces)
71
- .values({name: params.name, createdBy: params.userId})
76
+ .values({name: params.name, slug: params.slug, createdBy: params.userId})
77
+ .onConflictDoNothing({target: workspaces.slug})
72
78
  .returning();
73
- if (!workspaceRow) throw new Error('Insert returned no rows');
79
+ if (!workspaceRow) {
80
+ throw new WorkspaceSlugConflictError(params.slug);
81
+ }
74
82
  await tx.insert(memberships).values({
75
83
  userId: params.userId,
76
84
  userEmail: params.userEmail ?? `user-${params.userId}@example.local`,
@@ -84,6 +92,7 @@ export async function createWorkspaceForUser(params: {
84
92
  payload: {
85
93
  workspaceId: workspace.id,
86
94
  name: workspace.name,
95
+ slug: workspace.slug,
87
96
  creatorUserId: params.userId,
88
97
  },
89
98
  });
@@ -92,6 +101,42 @@ export async function createWorkspaceForUser(params: {
92
101
  });
93
102
  }
94
103
 
104
+ export async function checkWorkspaceSlugAvailability(slug: string): Promise<boolean> {
105
+ return await isWorkspaceSlugAvailable(slug);
106
+ }
107
+
108
+ export async function updateWorkspaceDetails(params: {
109
+ workspaceId: string;
110
+ name?: string | undefined;
111
+ slug?: string | undefined;
112
+ }): Promise<Workspace> {
113
+ try {
114
+ return await db().transaction(async (tx) => {
115
+ const workspace = await updateWorkspace(
116
+ {id: params.workspaceId, name: params.name, slug: params.slug},
117
+ {tx},
118
+ );
119
+ if (!workspace) throw new WorkspaceNotFoundError(params.workspaceId);
120
+
121
+ await writeOutboxEvent<WorkspacesEventMap>(tx, workspacesOutbox, {
122
+ type: WORKSPACES_WORKSPACE_UPDATED,
123
+ payload: {
124
+ workspaceId: workspace.id,
125
+ name: workspace.name,
126
+ slug: workspace.slug,
127
+ },
128
+ });
129
+
130
+ return workspace;
131
+ });
132
+ } catch (error) {
133
+ if (isUniqueViolation(error, 'workspaces_slug_unique') && params.slug) {
134
+ throw new WorkspaceSlugConflictError(params.slug);
135
+ }
136
+ throw error;
137
+ }
138
+ }
139
+
95
140
  export async function getWorkspace(params: {workspaceId: string}): Promise<Workspace> {
96
141
  const workspace = await getWorkspaceById(params.workspaceId);
97
142
  if (!workspace) {
@@ -128,6 +173,7 @@ export async function listWorkspaceMembers(params: {
128
173
  workspaceId: params.workspaceId,
129
174
  userId: params.requesterUserId,
130
175
  memberships: params.requesterMemberships,
176
+ enforceWorkspaceStatus: false,
131
177
  });
132
178
 
133
179
  return listMembershipsByWorkspace({workspaceId: params.workspaceId});
@@ -143,6 +189,7 @@ export async function removeWorkspaceMember(params: {
143
189
  workspaceId: params.workspaceId,
144
190
  userId: params.requesterUserId,
145
191
  memberships: params.requesterMemberships,
192
+ enforceWorkspaceStatus: false,
146
193
  });
147
194
 
148
195
  if (params.userId === params.requesterUserId) {
package/src/db/db.ts CHANGED
@@ -4,6 +4,7 @@ import {workspacesAdminCommandResults} from './schema/admin-command-results.js';
4
4
  import {invitations} from './schema/invitations.js';
5
5
  import {memberships} from './schema/memberships.js';
6
6
  import {workspacesOutbox} from './schema/outbox.js';
7
+ import {workspacesRateLimits} from './schema/rate-limits.js';
7
8
  import {workspaces} from './schema/workspaces.js';
8
9
 
9
10
  export const schema = {
@@ -12,6 +13,7 @@ export const schema = {
12
13
  memberships,
13
14
  invitations,
14
15
  workspacesOutbox,
16
+ workspacesRateLimits,
15
17
  };
16
18
 
17
19
  let _db: NodePgDatabase<typeof schema> | undefined;