@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
@@ -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,
@@ -0,0 +1,45 @@
1
+ import {ClientError, type FastifyReply, type FastifyRequest} from '@shipfox/node-fastify';
2
+ import {enforceRateLimit as enforceSharedRateLimit} from '@shipfox/node-rate-limit';
3
+ import {
4
+ checkWorkspacesRateLimit,
5
+ WORKSPACE_SLUG_AVAILABILITY_RATE_LIMIT,
6
+ } from '#core/rate-limit.js';
7
+
8
+ function routeName(request: FastifyRequest): string {
9
+ return request.routeOptions.url ?? request.url.split('?')[0] ?? 'unknown';
10
+ }
11
+
12
+ export function createWorkspaceSlugAvailabilityRateLimitPreHandler() {
13
+ return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
14
+ await enforceSharedRateLimit({
15
+ request,
16
+ reply,
17
+ check: () =>
18
+ checkWorkspacesRateLimit({
19
+ action: 'slug-availability',
20
+ scope: 'ip',
21
+ identifier: request.ip,
22
+ ...WORKSPACE_SLUG_AVAILABILITY_RATE_LIMIT,
23
+ }),
24
+ route: routeName(request),
25
+ unavailableCode: 'workspace-rate-limit-unavailable',
26
+ unavailableMessage: 'Workspace rate limiter unavailable',
27
+ setRetryAfter: (reply, retryAfterSeconds) => {
28
+ reply.header('Retry-After', String(retryAfterSeconds));
29
+ },
30
+ logWarn: (request, context) => {
31
+ request.log.warn(context, 'Workspace rate limit blocked request');
32
+ },
33
+ logError: (request, context) => {
34
+ request.log.error(context, 'Workspace rate limiter unavailable');
35
+ },
36
+ createClientError: (presentation, cause) =>
37
+ new ClientError(presentation.message, presentation.code, {
38
+ status: presentation.status,
39
+ ...(presentation.details ? {details: presentation.details} : {}),
40
+ data: presentation.data,
41
+ cause,
42
+ }),
43
+ });
44
+ };
45
+ }
@@ -47,13 +47,14 @@ describe('POST /workspaces', () => {
47
47
  const res = await app.inject({
48
48
  method: 'POST',
49
49
  url: '/workspaces',
50
- payload: {name: ' Test Workspace '},
50
+ payload: {name: ' Test Workspace ', slug: 'test-workspace'},
51
51
  });
52
52
 
53
53
  expect(res.statusCode).toBe(201);
54
54
  const body = res.json();
55
55
  expect(body.id).toBeDefined();
56
56
  expect(body.name).toBe('Test Workspace');
57
+ expect(body.slug).toBe('test-workspace');
57
58
  expect(body.status).toBe('active');
58
59
 
59
60
  const membership = await findMembership({userId, workspaceId: body.id});
@@ -69,7 +70,7 @@ describe('POST /workspaces', () => {
69
70
  const res = await app.inject({
70
71
  method: 'POST',
71
72
  url: '/workspaces',
72
- payload: {name},
73
+ payload: {name, slug: 'test-workspace'},
73
74
  });
74
75
 
75
76
  expect(res.statusCode).toBe(400);
@@ -79,9 +80,38 @@ describe('POST /workspaces', () => {
79
80
  const res = await app.inject({
80
81
  method: 'POST',
81
82
  url: '/workspaces',
82
- payload: {},
83
+ payload: {slug: 'test-workspace'},
83
84
  });
84
85
 
85
86
  expect(res.statusCode).toBe(400);
86
87
  });
88
+
89
+ test('accepts a workspace slugged settings', async () => {
90
+ const res = await app.inject({
91
+ method: 'POST',
92
+ url: '/workspaces',
93
+ payload: {name: 'Settings Workspace', slug: 'settings'},
94
+ });
95
+
96
+ expect(res.statusCode).toBe(201);
97
+ expect(res.json().slug).toBe('settings');
98
+ });
99
+
100
+ test('returns slug-conflict for a duplicate slug', async () => {
101
+ const slug = `duplicate-${crypto.randomUUID().slice(0, 8)}`;
102
+ const first = await app.inject({
103
+ method: 'POST',
104
+ url: '/workspaces',
105
+ payload: {name: 'First Workspace', slug},
106
+ });
107
+ const second = await app.inject({
108
+ method: 'POST',
109
+ url: '/workspaces',
110
+ payload: {name: 'Second Workspace', slug},
111
+ });
112
+
113
+ expect(first.statusCode).toBe(201);
114
+ expect(second.statusCode).toBe(409);
115
+ expect(second.json().code).toBe('slug-conflict');
116
+ });
87
117
  });
@@ -1,6 +1,7 @@
1
1
  import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
2
2
  import {createWorkspaceBodySchema, workspaceResponseSchema} from '@shipfox/api-workspaces-dto';
3
3
  import {ClientError, defineRoute} from '@shipfox/node-fastify';
4
+ import {WorkspaceSlugConflictError} from '#core/errors.js';
4
5
  import {createWorkspaceForUser} from '#core/index.js';
5
6
  import {toWorkspaceDto} from '#presentation/dto/index.js';
6
7
 
@@ -15,16 +16,23 @@ export const createWorkspaceRoute = defineRoute({
15
16
  201: workspaceResponseSchema,
16
17
  },
17
18
  },
19
+ errorHandler: (error) => {
20
+ if (error instanceof WorkspaceSlugConflictError) {
21
+ throw new ClientError('Workspace slug is already taken', 'slug-conflict', {status: 409});
22
+ }
23
+ throw error;
24
+ },
18
25
  handler: async (request, reply) => {
19
26
  const client = getUserContext(request);
20
27
  if (!client) {
21
28
  throw new ClientError('Authentication required', 'unauthorized', {status: 401});
22
29
  }
23
30
 
24
- const {name} = request.body;
31
+ const {name, slug} = request.body;
25
32
 
26
33
  const workspace = await createWorkspaceForUser({
27
34
  name,
35
+ slug,
28
36
  userId: client.userId,
29
37
  userEmail: client.email,
30
38
  userName: client.name,
@@ -1,2 +1,4 @@
1
1
  export {createWorkspaceRoute} from './create.js';
2
2
  export {listUserWorkspacesRoute} from './list.js';
3
+ export {workspaceSlugAvailabilityRoute} from './slug-availability.js';
4
+ export {updateWorkspaceRoute} from './update.js';
@@ -49,6 +49,7 @@ describe('GET /workspaces', () => {
49
49
  user_id: userId,
50
50
  workspace_id: workspace.id,
51
51
  workspace_name: 'Acme',
52
+ workspace_slug: workspace.slug,
52
53
  },
53
54
  ],
54
55
  });
@@ -0,0 +1,159 @@
1
+ import {sql} from 'drizzle-orm';
2
+ import {
3
+ hashWorkspacesRateLimitIdentifier,
4
+ WORKSPACE_SLUG_AVAILABILITY_RATE_LIMIT,
5
+ } from '#core/rate-limit.js';
6
+ import {createWorkspaceForUser} from '#core/workspaces.js';
7
+ import {db} from '#db/db.js';
8
+ import {workspacesRateLimits} from '#db/schema/rate-limits.js';
9
+ import {createWorkspacesTestApp, signupVerifyLogin} from '#test/routes.js';
10
+
11
+ let ipSequence = 1;
12
+
13
+ function uniqueIp(): string {
14
+ ipSequence += 1;
15
+ return `203.0.113.${ipSequence}`;
16
+ }
17
+
18
+ function windowStartFor(now: Date, windowSeconds: number): Date {
19
+ const windowMs = windowSeconds * 1000;
20
+ return new Date(Math.floor(now.getTime() / windowMs) * windowMs);
21
+ }
22
+
23
+ describe('GET /workspaces/slug-availability', () => {
24
+ test('returns true for an available slug and false for a taken slug', async () => {
25
+ const app = await createWorkspacesTestApp({fastifyOptions: {trustProxy: true}});
26
+ const user = await signupVerifyLogin(app, 'workspace-slug-availability');
27
+ const takenSlug = `taken-${crypto.randomUUID().slice(0, 8)}`;
28
+ await createWorkspaceForUser({
29
+ name: 'Taken Workspace',
30
+ slug: takenSlug,
31
+ userId: crypto.randomUUID(),
32
+ });
33
+
34
+ const headers = {
35
+ authorization: `Bearer ${user.token}`,
36
+ 'x-forwarded-for': uniqueIp(),
37
+ };
38
+ const available = await app.inject({
39
+ method: 'GET',
40
+ url: `/workspaces/slug-availability?slug=available-${crypto.randomUUID().slice(0, 8)}`,
41
+ headers,
42
+ });
43
+ const taken = await app.inject({
44
+ method: 'GET',
45
+ url: `/workspaces/slug-availability?slug=${takenSlug}`,
46
+ headers: {...headers, 'x-forwarded-for': uniqueIp()},
47
+ });
48
+
49
+ expect(available.statusCode).toBe(200);
50
+ expect(available.json()).toEqual({available: true});
51
+ expect(taken.statusCode).toBe(200);
52
+ expect(taken.json()).toEqual({available: false});
53
+
54
+ await app.close();
55
+ });
56
+
57
+ test('rejects malformed slugs instead of reporting them as taken', async () => {
58
+ const app = await createWorkspacesTestApp({fastifyOptions: {trustProxy: true}});
59
+ const user = await signupVerifyLogin(app, 'workspace-slug-invalid');
60
+
61
+ const res = await app.inject({
62
+ method: 'GET',
63
+ url: '/workspaces/slug-availability?slug=Not%20A%20Slug',
64
+ headers: {
65
+ authorization: `Bearer ${user.token}`,
66
+ 'x-forwarded-for': uniqueIp(),
67
+ },
68
+ });
69
+
70
+ expect(res.statusCode).toBe(400);
71
+
72
+ await app.close();
73
+ });
74
+
75
+ test('rejects unauthenticated callers', async () => {
76
+ const app = await createWorkspacesTestApp({fastifyOptions: {trustProxy: true}});
77
+
78
+ const res = await app.inject({
79
+ method: 'GET',
80
+ url: '/workspaces/slug-availability?slug=available',
81
+ });
82
+
83
+ expect(res.statusCode).toBe(401);
84
+
85
+ await app.close();
86
+ });
87
+
88
+ test('rate-limits repeated availability checks by source IP', async () => {
89
+ const app = await createWorkspacesTestApp({fastifyOptions: {trustProxy: true}});
90
+ const user = await signupVerifyLogin(app, 'workspace-slug-rate-limit');
91
+ const headers = {
92
+ authorization: `Bearer ${user.token}`,
93
+ 'x-forwarded-for': uniqueIp(),
94
+ };
95
+ const url = `/workspaces/slug-availability?slug=rate-limit-${crypto.randomUUID().slice(0, 8)}`;
96
+ let lastResponse = await app.inject({method: 'GET', url, headers});
97
+
98
+ for (let attempt = 1; attempt <= WORKSPACE_SLUG_AVAILABILITY_RATE_LIMIT.limit; attempt += 1) {
99
+ lastResponse = await app.inject({method: 'GET', url, headers});
100
+ }
101
+
102
+ expect(lastResponse.statusCode).toBe(429);
103
+ expect(lastResponse.json()).toMatchObject({
104
+ code: 'rate-limited',
105
+ details: {retry_after_seconds: expect.any(Number)},
106
+ });
107
+ expect(lastResponse.headers['retry-after']).toEqual(expect.any(String));
108
+
109
+ await app.close();
110
+ });
111
+
112
+ test('fails closed when limiter storage is unavailable', async () => {
113
+ const app = await createWorkspacesTestApp({fastifyOptions: {trustProxy: true}});
114
+ const user = await signupVerifyLogin(app, 'workspace-slug-rate-limit-unavailable');
115
+ const ip = uniqueIp();
116
+ const now = new Date();
117
+ const windowStart = windowStartFor(now, WORKSPACE_SLUG_AVAILABILITY_RATE_LIMIT.windowSeconds);
118
+ const identifierHmac = hashWorkspacesRateLimitIdentifier({
119
+ action: 'slug-availability',
120
+ scope: 'ip',
121
+ identifier: ip,
122
+ });
123
+
124
+ await db()
125
+ .insert(workspacesRateLimits)
126
+ .values({
127
+ action: 'slug-availability',
128
+ scope: 'ip',
129
+ identifierHmac,
130
+ windowStart,
131
+ count: 1,
132
+ expiresAt: new Date(
133
+ windowStart.getTime() + WORKSPACE_SLUG_AVAILABILITY_RATE_LIMIT.windowSeconds * 1000,
134
+ ),
135
+ });
136
+
137
+ await db().transaction(async (tx) => {
138
+ await tx.execute(sql`
139
+ SELECT 1
140
+ FROM workspaces_rate_limits
141
+ WHERE identifier_hmac = ${identifierHmac}
142
+ FOR UPDATE
143
+ `);
144
+ const res = await app.inject({
145
+ method: 'GET',
146
+ url: `/workspaces/slug-availability?slug=unavailable-${crypto.randomUUID().slice(0, 8)}`,
147
+ headers: {
148
+ authorization: `Bearer ${user.token}`,
149
+ 'x-forwarded-for': ip,
150
+ },
151
+ });
152
+
153
+ expect(res.statusCode).toBe(503);
154
+ expect(res.json()).toEqual({code: 'workspace-rate-limit-unavailable'});
155
+ });
156
+
157
+ await app.close();
158
+ });
159
+ });
@@ -0,0 +1,30 @@
1
+ import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
2
+ import {
3
+ workspaceSlugAvailabilityQuerySchema,
4
+ workspaceSlugAvailabilityResponseSchema,
5
+ } from '@shipfox/api-workspaces-dto';
6
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
7
+ import {checkWorkspaceSlugAvailability} from '#core/index.js';
8
+ import {createWorkspaceSlugAvailabilityRateLimitPreHandler} from '../rate-limit.js';
9
+
10
+ export const workspaceSlugAvailabilityRoute = defineRoute({
11
+ method: 'GET',
12
+ path: '/slug-availability',
13
+ description: 'Check whether a workspace slug is available.',
14
+ auth: AUTH_USER,
15
+ schema: {
16
+ querystring: workspaceSlugAvailabilityQuerySchema,
17
+ response: {
18
+ 200: workspaceSlugAvailabilityResponseSchema,
19
+ },
20
+ },
21
+ preHandler: createWorkspaceSlugAvailabilityRateLimitPreHandler(),
22
+ handler: async (request) => {
23
+ const client = getUserContext(request);
24
+ if (!client) {
25
+ throw new ClientError('Authentication required', 'unauthorized', {status: 401});
26
+ }
27
+
28
+ return {available: await checkWorkspaceSlugAvailability(request.query.slug)};
29
+ },
30
+ });
@@ -0,0 +1,114 @@
1
+ import {WORKSPACES_WORKSPACE_UPDATED} from '@shipfox/api-workspaces-dto';
2
+ import {sql} from 'drizzle-orm';
3
+ import {createWorkspaceForUser} from '#core/workspaces.js';
4
+ import {db} from '#db/db.js';
5
+ import {workspacesOutbox} from '#db/schema/outbox.js';
6
+ import {createWorkspacesTestApp, signupVerifyLogin} from '#test/routes.js';
7
+
8
+ describe('PATCH /workspaces/:workspaceId', () => {
9
+ test('updates workspace details and writes the updated event', async () => {
10
+ const app = await createWorkspacesTestApp();
11
+ const owner = await signupVerifyLogin(app, 'workspace-update-owner');
12
+ const workspace = await createWorkspaceForUser({
13
+ name: 'Before',
14
+ slug: `before-${crypto.randomUUID().slice(0, 8)}`,
15
+ userId: owner.userId,
16
+ userEmail: owner.email,
17
+ });
18
+
19
+ const res = await app.inject({
20
+ method: 'PATCH',
21
+ url: `/workspaces/${workspace.id}`,
22
+ headers: {authorization: `Bearer ${owner.token}`},
23
+ payload: {name: 'After', slug: 'after'},
24
+ });
25
+
26
+ expect(res.statusCode).toBe(200);
27
+ expect(res.json()).toMatchObject({id: workspace.id, name: 'After', slug: 'after'});
28
+
29
+ const [event] = await db()
30
+ .select()
31
+ .from(workspacesOutbox)
32
+ .where(sql`${workspacesOutbox.payload}->>'workspaceId' = ${workspace.id}`)
33
+ .orderBy(sql`${workspacesOutbox.createdAt} DESC`)
34
+ .limit(1);
35
+ expect(event).toMatchObject({
36
+ eventType: WORKSPACES_WORKSPACE_UPDATED,
37
+ payload: {workspaceId: workspace.id, name: 'After', slug: 'after'},
38
+ });
39
+ });
40
+
41
+ test('rejects an empty body instead of writing a no-op updated event', async () => {
42
+ const app = await createWorkspacesTestApp();
43
+ const owner = await signupVerifyLogin(app, 'workspace-update-empty');
44
+ const workspace = await createWorkspaceForUser({
45
+ name: 'Untouched',
46
+ slug: `untouched-${crypto.randomUUID().slice(0, 8)}`,
47
+ userId: owner.userId,
48
+ userEmail: owner.email,
49
+ });
50
+
51
+ const res = await app.inject({
52
+ method: 'PATCH',
53
+ url: `/workspaces/${workspace.id}`,
54
+ headers: {authorization: `Bearer ${owner.token}`},
55
+ payload: {},
56
+ });
57
+
58
+ expect(res.statusCode).toBe(400);
59
+ });
60
+
61
+ test('returns slug-conflict for a taken slug', async () => {
62
+ const app = await createWorkspacesTestApp();
63
+ const owner = await signupVerifyLogin(app, 'workspace-update-conflict');
64
+ await createWorkspaceForUser({
65
+ name: 'Taken',
66
+ slug: 'taken',
67
+ userId: crypto.randomUUID(),
68
+ });
69
+ const workspace = await createWorkspaceForUser({
70
+ name: 'Available',
71
+ slug: `available-${crypto.randomUUID().slice(0, 8)}`,
72
+ userId: owner.userId,
73
+ userEmail: owner.email,
74
+ });
75
+
76
+ const res = await app.inject({
77
+ method: 'PATCH',
78
+ url: `/workspaces/${workspace.id}`,
79
+ headers: {authorization: `Bearer ${owner.token}`},
80
+ payload: {slug: 'taken'},
81
+ });
82
+
83
+ expect(res.statusCode).toBe(409);
84
+ expect(res.json().code).toBe('slug-conflict');
85
+ });
86
+
87
+ test('frees the old slug for immediate reuse', async () => {
88
+ const app = await createWorkspacesTestApp();
89
+ const owner = await signupVerifyLogin(app, 'workspace-update-reuse');
90
+ const oldSlug = `old-${crypto.randomUUID().slice(0, 8)}`;
91
+ const workspace = await createWorkspaceForUser({
92
+ name: 'Renamed',
93
+ slug: oldSlug,
94
+ userId: owner.userId,
95
+ userEmail: owner.email,
96
+ });
97
+
98
+ const renamed = await app.inject({
99
+ method: 'PATCH',
100
+ url: `/workspaces/${workspace.id}`,
101
+ headers: {authorization: `Bearer ${owner.token}`},
102
+ payload: {slug: 'renamed'},
103
+ });
104
+
105
+ expect(renamed.statusCode).toBe(200);
106
+ await expect(
107
+ createWorkspaceForUser({
108
+ name: 'Reused',
109
+ slug: oldSlug,
110
+ userId: crypto.randomUUID(),
111
+ }),
112
+ ).resolves.toMatchObject({slug: oldSlug});
113
+ });
114
+ });
@@ -0,0 +1,41 @@
1
+ import {AUTH_USER, requireWorkspaceAccess} from '@shipfox/api-auth-context';
2
+ import {updateWorkspaceBodySchema, workspaceResponseSchema} from '@shipfox/api-workspaces-dto';
3
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
4
+ import {z} from 'zod';
5
+ import {WorkspaceNotFoundError, WorkspaceSlugConflictError} from '#core/errors.js';
6
+ import {updateWorkspaceDetails} from '#core/index.js';
7
+ import {toWorkspaceDto} from '#presentation/dto/index.js';
8
+
9
+ export const updateWorkspaceRoute = defineRoute({
10
+ method: 'PATCH',
11
+ path: '/:workspaceId',
12
+ description: 'Update workspace details.',
13
+ auth: AUTH_USER,
14
+ schema: {
15
+ params: z.object({workspaceId: z.string().uuid()}),
16
+ body: updateWorkspaceBodySchema,
17
+ response: {
18
+ 200: workspaceResponseSchema,
19
+ },
20
+ },
21
+ errorHandler: (error) => {
22
+ if (error instanceof WorkspaceNotFoundError) {
23
+ throw new ClientError('Workspace not found', 'not-found', {status: 404});
24
+ }
25
+ if (error instanceof WorkspaceSlugConflictError) {
26
+ throw new ClientError('Workspace slug is already taken', 'slug-conflict', {status: 409});
27
+ }
28
+ throw error;
29
+ },
30
+ handler: async (request) => {
31
+ requireWorkspaceAccess({request, workspaceId: request.params.workspaceId});
32
+
33
+ const workspace = await updateWorkspaceDetails({
34
+ workspaceId: request.params.workspaceId,
35
+ name: request.body.name,
36
+ slug: request.body.slug,
37
+ });
38
+
39
+ return toWorkspaceDto(workspace);
40
+ },
41
+ });
package/test/env.ts CHANGED
@@ -5,4 +5,5 @@ process.env.POSTGRES_PASSWORD ??= 'password';
5
5
  process.env.POSTGRES_DATABASE = 'api_test';
6
6
  process.env.POSTGRES_MAX_CONNECTIONS ??= '5';
7
7
  process.env.WORKSPACE_JWT_SECRET = 'test-secret';
8
+ process.env.AUTH_ROOT_KEY = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
8
9
  process.env.TZ = 'UTC';
@@ -1,15 +1,17 @@
1
+ import {withSlugSuffix} from '@shipfox/api-common-dto';
1
2
  import {Factory} from 'fishery';
2
3
  import type {Workspace} from '#core/entities/workspace.js';
3
4
  import {createWorkspace} from '#db/workspaces.js';
4
5
 
5
6
  export const workspaceFactory = Factory.define<Workspace>(({sequence, onCreate}) => {
6
7
  onCreate((workspace) => {
7
- return createWorkspace({name: workspace.name});
8
+ return createWorkspace({name: workspace.name, slug: workspace.slug});
8
9
  });
9
10
 
10
11
  return {
11
12
  id: crypto.randomUUID(),
12
13
  name: `Test Workspace ${sequence}`,
14
+ slug: withSlugSuffix(`test-workspace-${crypto.randomUUID().slice(0, 8)}`, sequence + 1),
13
15
  status: 'active',
14
16
  settings: {},
15
17
  createdBy: null,
@@ -9,7 +9,7 @@ export async function setup() {
9
9
 
10
10
  await runMigrations(db(), migrationsPath, '__drizzle_migrations_workspaces');
11
11
  await db().execute(
12
- sql`TRUNCATE workspaces_admin_command_results, workspaces_outbox, workspaces_workspaces CASCADE`,
12
+ sql`TRUNCATE workspaces_admin_command_results, workspaces_outbox, workspaces_rate_limits, workspaces_workspaces CASCADE`,
13
13
  );
14
14
 
15
15
  closeDb();