@shipfox/api-workspaces 11.0.0 → 12.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) 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.js +1 -1
  16. package/dist/core/invitations.js.map +1 -1
  17. package/dist/core/rate-limit.d.ts +39 -0
  18. package/dist/core/rate-limit.d.ts.map +1 -0
  19. package/dist/core/rate-limit.js +70 -0
  20. package/dist/core/rate-limit.js.map +1 -0
  21. package/dist/core/workspaces.d.ts +7 -0
  22. package/dist/core/workspaces.d.ts.map +1 -1
  23. package/dist/core/workspaces.js +42 -4
  24. package/dist/core/workspaces.js.map +1 -1
  25. package/dist/db/db.d.ts +332 -12
  26. package/dist/db/db.d.ts.map +1 -1
  27. package/dist/db/db.js +3 -1
  28. package/dist/db/db.js.map +1 -1
  29. package/dist/db/index.d.ts +4 -1
  30. package/dist/db/index.d.ts.map +1 -1
  31. package/dist/db/index.js +3 -1
  32. package/dist/db/index.js.map +1 -1
  33. package/dist/db/memberships.d.ts +1 -0
  34. package/dist/db/memberships.d.ts.map +1 -1
  35. package/dist/db/memberships.js +2 -0
  36. package/dist/db/memberships.js.map +1 -1
  37. package/dist/db/rate-limits.d.ts +9 -0
  38. package/dist/db/rate-limits.d.ts.map +1 -0
  39. package/dist/db/rate-limits.js +43 -0
  40. package/dist/db/rate-limits.js.map +1 -0
  41. package/dist/db/schema/admin-command-results.d.ts +1 -1
  42. package/dist/db/schema/invitations.d.ts +1 -1
  43. package/dist/db/schema/memberships.d.ts +1 -1
  44. package/dist/db/schema/outbox.d.ts +1 -1
  45. package/dist/db/schema/rate-limits.d.ts +145 -0
  46. package/dist/db/schema/rate-limits.d.ts.map +1 -0
  47. package/dist/db/schema/rate-limits.js +25 -0
  48. package/dist/db/schema/rate-limits.js.map +1 -0
  49. package/dist/db/schema/workspaces.d.ts +19 -2
  50. package/dist/db/schema/workspaces.d.ts.map +1 -1
  51. package/dist/db/schema/workspaces.js +5 -2
  52. package/dist/db/schema/workspaces.js.map +1 -1
  53. package/dist/db/workspaces.d.ts +11 -1
  54. package/dist/db/workspaces.d.ts.map +1 -1
  55. package/dist/db/workspaces.js +12 -3
  56. package/dist/db/workspaces.js.map +1 -1
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +1 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/metrics/instance.d.ts +6 -0
  62. package/dist/metrics/instance.d.ts.map +1 -1
  63. package/dist/metrics/instance.js +12 -0
  64. package/dist/metrics/instance.js.map +1 -1
  65. package/dist/presentation/dto/membership.d.ts.map +1 -1
  66. package/dist/presentation/dto/membership.js +1 -0
  67. package/dist/presentation/dto/membership.js.map +1 -1
  68. package/dist/presentation/dto/workspace.d.ts.map +1 -1
  69. package/dist/presentation/dto/workspace.js +1 -0
  70. package/dist/presentation/dto/workspace.js.map +1 -1
  71. package/dist/presentation/e2eRoutes/create-workspace.d.ts.map +1 -1
  72. package/dist/presentation/e2eRoutes/create-workspace.js +11 -1
  73. package/dist/presentation/e2eRoutes/create-workspace.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/rate-limit.d.ts +3 -0
  78. package/dist/presentation/routes/rate-limit.d.ts.map +1 -0
  79. package/dist/presentation/routes/rate-limit.js +42 -0
  80. package/dist/presentation/routes/rate-limit.js.map +1 -0
  81. package/dist/presentation/routes/workspaces/create.d.ts.map +1 -1
  82. package/dist/presentation/routes/workspaces/create.js +11 -1
  83. package/dist/presentation/routes/workspaces/create.js.map +1 -1
  84. package/dist/presentation/routes/workspaces/index.d.ts +2 -0
  85. package/dist/presentation/routes/workspaces/index.d.ts.map +1 -1
  86. package/dist/presentation/routes/workspaces/index.js +2 -0
  87. package/dist/presentation/routes/workspaces/index.js.map +1 -1
  88. package/dist/presentation/routes/workspaces/slug-availability.d.ts +2 -0
  89. package/dist/presentation/routes/workspaces/slug-availability.d.ts.map +1 -0
  90. package/dist/presentation/routes/workspaces/slug-availability.js +31 -0
  91. package/dist/presentation/routes/workspaces/slug-availability.js.map +1 -0
  92. package/dist/presentation/routes/workspaces/update.d.ts +2 -0
  93. package/dist/presentation/routes/workspaces/update.d.ts.map +1 -0
  94. package/dist/presentation/routes/workspaces/update.js +49 -0
  95. package/dist/presentation/routes/workspaces/update.js.map +1 -0
  96. package/dist/tsconfig.test.tsbuildinfo +1 -1
  97. package/drizzle/0000_initial.sql +2 -0
  98. package/drizzle/0003_dapper_talos.sql +13 -0
  99. package/drizzle/meta/0000_snapshot.json +23 -1
  100. package/drizzle/meta/0003_snapshot.json +689 -0
  101. package/drizzle/meta/_journal.json +7 -0
  102. package/package.json +16 -14
  103. package/src/core/entities/workspace.ts +1 -0
  104. package/src/core/errors.ts +7 -0
  105. package/src/core/index.ts +3 -0
  106. package/src/core/invitations.ts +1 -1
  107. package/src/core/rate-limit.test.ts +113 -0
  108. package/src/core/rate-limit.ts +119 -0
  109. package/src/core/workspaces.test.ts +9 -0
  110. package/src/core/workspaces.ts +47 -3
  111. package/src/db/db.ts +2 -0
  112. package/src/db/index.ts +10 -0
  113. package/src/db/memberships.ts +3 -0
  114. package/src/db/rate-limits.test.ts +74 -0
  115. package/src/db/rate-limits.ts +59 -0
  116. package/src/db/schema/rate-limits.ts +27 -0
  117. package/src/db/schema/workspaces.ts +7 -2
  118. package/src/db/workspaces.ts +21 -1
  119. package/src/index.test.ts +2 -0
  120. package/src/index.ts +1 -0
  121. package/src/metrics/instance.ts +26 -0
  122. package/src/presentation/dto/membership.ts +1 -0
  123. package/src/presentation/dto/workspace.ts +1 -0
  124. package/src/presentation/e2eRoutes/create-workspace.ts +9 -1
  125. package/src/presentation/inter-module.test.ts +22 -4
  126. package/src/presentation/routes/index.ts +8 -1
  127. package/src/presentation/routes/rate-limit.ts +45 -0
  128. package/src/presentation/routes/workspaces/create.test.ts +33 -3
  129. package/src/presentation/routes/workspaces/create.ts +9 -1
  130. package/src/presentation/routes/workspaces/index.ts +2 -0
  131. package/src/presentation/routes/workspaces/list.test.ts +1 -0
  132. package/src/presentation/routes/workspaces/slug-availability.test.ts +159 -0
  133. package/src/presentation/routes/workspaces/slug-availability.ts +30 -0
  134. package/src/presentation/routes/workspaces/update.test.ts +114 -0
  135. package/src/presentation/routes/workspaces/update.ts +41 -0
  136. package/test/env.ts +1 -0
  137. package/test/factories/workspace.ts +3 -1
  138. package/test/globalSetup.ts +1 -1
  139. package/test/routes.ts +18 -4
  140. 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": "11.0.0",
4
+ "version": "12.2.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-context": "11.0.0",
29
- "@shipfox/api-auth-dto": "10.2.0",
30
- "@shipfox/api-projects-dto": "10.1.0",
31
- "@shipfox/api-runners-dto": "10.2.0",
32
- "@shipfox/api-workspaces-dto": "11.0.0",
27
+ "@shipfox/api-common-dto": "12.0.0",
28
+ "@shipfox/api-auth-dto": "12.0.0",
29
+ "@shipfox/api-projects-dto": "12.2.0",
30
+ "@shipfox/api-auth-context": "12.2.0",
31
+ "@shipfox/api-runners-dto": "12.2.0",
33
32
  "@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",
33
+ "@shipfox/inter-module": "0.2.3",
34
+ "@shipfox/api-workspaces-dto": "12.0.0",
35
+ "@shipfox/node-auth-root-key": "0.2.3",
36
+ "@shipfox/node-drizzle": "0.3.5",
37
+ "@shipfox/node-fastify": "0.4.2",
37
38
  "@shipfox/node-email": "0.3.4",
38
- "@shipfox/node-mailer": "0.2.4",
39
- "@shipfox/node-module": "1.0.4",
40
- "@shipfox/node-opentelemetry": "0.6.3",
41
- "@shipfox/node-postgres": "0.4.4",
39
+ "@shipfox/node-mailer": "0.2.5",
40
+ "@shipfox/node-module": "1.0.6",
41
+ "@shipfox/node-opentelemetry": "0.6.4",
42
42
  "@shipfox/node-outbox": "0.2.6",
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';
@@ -100,7 +100,7 @@ export async function previewInvitation(params: {token: string}): Promise<Previe
100
100
 
101
101
  const workspace = await getWorkspaceById(invitation.workspaceId);
102
102
  if (!workspace) {
103
- // Workspace cascade-deleted the invitation row is orphaned. Treat as invalid.
103
+ // Workspace cascade-deleted: the invitation row is orphaned. Treat as invalid.
104
104
  return {status: 'invalid'};
105
105
  }
106
106
 
@@ -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
+ }
@@ -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,
@@ -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
 
@@ -89,6 +96,7 @@ describe('workspaces core', () => {
89
96
  const user = userFactory.build();
90
97
  const workspace = await createWorkspaceForUser({
91
98
  name: 'Suspended Workspace',
99
+ slug: `suspended-${crypto.randomUUID().slice(0, 8)}`,
92
100
  userId: user.userId,
93
101
  userEmail: user.email,
94
102
  userName: user.name,
@@ -109,6 +117,7 @@ describe('workspaces core', () => {
109
117
  const outsider = userFactory.build();
110
118
  const workspace = await createWorkspaceForUser({
111
119
  name: 'Private Workspace',
120
+ slug: `private-${crypto.randomUUID().slice(0, 8)}`,
112
121
  userId: owner.userId,
113
122
  userEmail: owner.email,
114
123
  userName: owner.name,
@@ -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,6 +27,7 @@ import {
25
27
  SelfRemovalNotAllowedError,
26
28
  WorkspaceInactiveError,
27
29
  WorkspaceNotFoundError,
30
+ WorkspaceSlugConflictError,
28
31
  } from './errors.js';
29
32
 
30
33
  export interface RequireWorkspaceMembershipParams {
@@ -62,6 +65,7 @@ export async function requireWorkspaceMembership(
62
65
 
63
66
  export async function createWorkspaceForUser(params: {
64
67
  name: string;
68
+ slug: string;
65
69
  userId: string;
66
70
  userEmail?: string | undefined;
67
71
  userName?: string | null | undefined;
@@ -69,9 +73,12 @@ export async function createWorkspaceForUser(params: {
69
73
  return await db().transaction(async (tx) => {
70
74
  const [workspaceRow] = await tx
71
75
  .insert(workspaces)
72
- .values({name: params.name, createdBy: params.userId})
76
+ .values({name: params.name, slug: params.slug, createdBy: params.userId})
77
+ .onConflictDoNothing({target: workspaces.slug})
73
78
  .returning();
74
- if (!workspaceRow) throw new Error('Insert returned no rows');
79
+ if (!workspaceRow) {
80
+ throw new WorkspaceSlugConflictError(params.slug);
81
+ }
75
82
  await tx.insert(memberships).values({
76
83
  userId: params.userId,
77
84
  userEmail: params.userEmail ?? `user-${params.userId}@example.local`,
@@ -85,6 +92,7 @@ export async function createWorkspaceForUser(params: {
85
92
  payload: {
86
93
  workspaceId: workspace.id,
87
94
  name: workspace.name,
95
+ slug: workspace.slug,
88
96
  creatorUserId: params.userId,
89
97
  },
90
98
  });
@@ -93,6 +101,42 @@ export async function createWorkspaceForUser(params: {
93
101
  });
94
102
  }
95
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
+
96
140
  export async function getWorkspace(params: {workspaceId: string}): Promise<Workspace> {
97
141
  const workspace = await getWorkspaceById(params.workspaceId);
98
142
  if (!workspace) {
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;
package/src/db/index.ts CHANGED
@@ -22,8 +22,17 @@ export {
22
22
  listMembershipsByWorkspace,
23
23
  removeMembership,
24
24
  } from './memberships.js';
25
+ export type {
26
+ ConsumeWorkspacesRateLimitParams,
27
+ ConsumeWorkspacesRateLimitResult,
28
+ } from './rate-limits.js';
29
+ export {
30
+ consumeWorkspacesRateLimit,
31
+ pruneExpiredWorkspacesRateLimits,
32
+ } from './rate-limits.js';
25
33
  export {workspacesAdminCommandResults} from './schema/admin-command-results.js';
26
34
  export {workspacesOutbox} from './schema/outbox.js';
35
+ export {workspacesRateLimits} from './schema/rate-limits.js';
27
36
  export type {
28
37
  AdminWorkspaceRow,
29
38
  CreateWorkspaceParams,
@@ -36,6 +45,7 @@ export {
36
45
  createWorkspace,
37
46
  getWorkspaceById,
38
47
  getWorkspaceServiceMetrics,
48
+ isWorkspaceSlugAvailable,
39
49
  listAdminWorkspaces,
40
50
  updateWorkspace,
41
51
  } from './workspaces.js';
@@ -59,6 +59,7 @@ export async function ensureMembership(params: EnsureMembershipParams): Promise<
59
59
 
60
60
  export interface MembershipWithWorkspace extends Membership {
61
61
  workspaceName: string;
62
+ workspaceSlug: string;
62
63
  workspaceStatus: Workspace['status'];
63
64
  }
64
65
 
@@ -69,6 +70,7 @@ export async function listMembershipsByUser(params: {
69
70
  .select({
70
71
  membership: memberships,
71
72
  workspaceName: workspaces.name,
73
+ workspaceSlug: workspaces.slug,
72
74
  workspaceStatus: workspaces.status,
73
75
  })
74
76
  .from(memberships)
@@ -79,6 +81,7 @@ export async function listMembershipsByUser(params: {
79
81
  return rows.map((row) => ({
80
82
  ...toMembership(row.membership),
81
83
  workspaceName: row.workspaceName,
84
+ workspaceSlug: row.workspaceSlug,
82
85
  workspaceStatus: row.workspaceStatus,
83
86
  }));
84
87
  }
@@ -0,0 +1,74 @@
1
+ import {and, eq, sql} from 'drizzle-orm';
2
+ import {consumeWorkspacesRateLimit, pruneExpiredWorkspacesRateLimits} from '#db/rate-limits.js';
3
+ import {db} from './db.js';
4
+ import {workspacesRateLimits} from './schema/rate-limits.js';
5
+
6
+ describe('workspaces rate limits db', () => {
7
+ it('atomically increments one bucket for concurrent attempts', async () => {
8
+ const identifierHmac = `hmac-${crypto.randomUUID()}`;
9
+ const params = {
10
+ action: 'slug-availability',
11
+ scope: 'ip',
12
+ identifierHmac,
13
+ windowStart: new Date('2026-06-23T00:00:00Z'),
14
+ expiresAt: new Date('2026-06-23T00:01:00Z'),
15
+ timeoutMs: 5_000,
16
+ };
17
+
18
+ await Promise.all(Array.from({length: 20}, () => consumeWorkspacesRateLimit(params)));
19
+
20
+ const [row] = await db()
21
+ .select({count: workspacesRateLimits.count})
22
+ .from(workspacesRateLimits)
23
+ .where(eq(workspacesRateLimits.identifierHmac, identifierHmac));
24
+ expect(row?.count).toBe(20);
25
+ });
26
+
27
+ it('prunes expired counters and keeps active counters', async () => {
28
+ const expiredIdentifierHmac = `expired-${crypto.randomUUID()}`;
29
+ const activeIdentifierHmac = `active-${crypto.randomUUID()}`;
30
+ await db()
31
+ .insert(workspacesRateLimits)
32
+ .values([
33
+ {
34
+ action: 'slug-availability',
35
+ scope: 'ip',
36
+ identifierHmac: expiredIdentifierHmac,
37
+ windowStart: new Date('2026-06-23T00:00:00Z'),
38
+ count: 1,
39
+ expiresAt: new Date('2026-06-23T00:01:00Z'),
40
+ },
41
+ {
42
+ action: 'slug-availability',
43
+ scope: 'ip',
44
+ identifierHmac: activeIdentifierHmac,
45
+ windowStart: new Date('2026-06-23T00:04:00Z'),
46
+ count: 1,
47
+ expiresAt: new Date('2026-06-23T00:06:00Z'),
48
+ },
49
+ ]);
50
+
51
+ const result = await pruneExpiredWorkspacesRateLimits({
52
+ now: new Date('2026-06-23T00:05:00Z'),
53
+ minIntervalMs: 0,
54
+ });
55
+
56
+ expect(result).toBeGreaterThanOrEqual(1);
57
+ expect(await countBucket(expiredIdentifierHmac)).toBe(0);
58
+ expect(await countBucket(activeIdentifierHmac)).toBe(1);
59
+ });
60
+
61
+ async function countBucket(identifierHmac: string): Promise<number> {
62
+ const [row] = await db()
63
+ .select({count: sql<number>`count(*)::int`})
64
+ .from(workspacesRateLimits)
65
+ .where(
66
+ and(
67
+ eq(workspacesRateLimits.action, 'slug-availability'),
68
+ eq(workspacesRateLimits.scope, 'ip'),
69
+ eq(workspacesRateLimits.identifierHmac, identifierHmac),
70
+ ),
71
+ );
72
+ return row?.count ?? 0;
73
+ }
74
+ });