@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +48 -0
- package/README.md +27 -4
- package/dist/core/entities/workspace.d.ts +1 -0
- package/dist/core/entities/workspace.d.ts.map +1 -1
- package/dist/core/entities/workspace.js.map +1 -1
- package/dist/core/errors.d.ts +3 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +6 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/invitations.js +1 -1
- package/dist/core/invitations.js.map +1 -1
- package/dist/core/rate-limit.d.ts +39 -0
- package/dist/core/rate-limit.d.ts.map +1 -0
- package/dist/core/rate-limit.js +70 -0
- package/dist/core/rate-limit.js.map +1 -0
- package/dist/core/workspaces.d.ts +7 -0
- package/dist/core/workspaces.d.ts.map +1 -1
- package/dist/core/workspaces.js +42 -4
- package/dist/core/workspaces.js.map +1 -1
- package/dist/db/db.d.ts +332 -12
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +3 -1
- package/dist/db/db.js.map +1 -1
- package/dist/db/index.d.ts +4 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +3 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/memberships.d.ts +1 -0
- package/dist/db/memberships.d.ts.map +1 -1
- package/dist/db/memberships.js +2 -0
- package/dist/db/memberships.js.map +1 -1
- package/dist/db/rate-limits.d.ts +9 -0
- package/dist/db/rate-limits.d.ts.map +1 -0
- package/dist/db/rate-limits.js +43 -0
- package/dist/db/rate-limits.js.map +1 -0
- package/dist/db/schema/admin-command-results.d.ts +1 -1
- package/dist/db/schema/invitations.d.ts +1 -1
- package/dist/db/schema/memberships.d.ts +1 -1
- package/dist/db/schema/outbox.d.ts +1 -1
- package/dist/db/schema/rate-limits.d.ts +145 -0
- package/dist/db/schema/rate-limits.d.ts.map +1 -0
- package/dist/db/schema/rate-limits.js +25 -0
- package/dist/db/schema/rate-limits.js.map +1 -0
- package/dist/db/schema/workspaces.d.ts +19 -2
- package/dist/db/schema/workspaces.d.ts.map +1 -1
- package/dist/db/schema/workspaces.js +5 -2
- package/dist/db/schema/workspaces.js.map +1 -1
- package/dist/db/workspaces.d.ts +11 -1
- package/dist/db/workspaces.d.ts.map +1 -1
- package/dist/db/workspaces.js +12 -3
- package/dist/db/workspaces.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/metrics/instance.d.ts +6 -0
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +12 -0
- package/dist/metrics/instance.js.map +1 -1
- package/dist/presentation/dto/membership.d.ts.map +1 -1
- package/dist/presentation/dto/membership.js +1 -0
- package/dist/presentation/dto/membership.js.map +1 -1
- package/dist/presentation/dto/workspace.d.ts.map +1 -1
- package/dist/presentation/dto/workspace.js +1 -0
- package/dist/presentation/dto/workspace.js.map +1 -1
- package/dist/presentation/e2eRoutes/create-workspace.d.ts.map +1 -1
- package/dist/presentation/e2eRoutes/create-workspace.js +11 -1
- package/dist/presentation/e2eRoutes/create-workspace.js.map +1 -1
- package/dist/presentation/routes/index.d.ts.map +1 -1
- package/dist/presentation/routes/index.js +3 -1
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/presentation/routes/rate-limit.d.ts +3 -0
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -0
- package/dist/presentation/routes/rate-limit.js +42 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -0
- package/dist/presentation/routes/workspaces/create.d.ts.map +1 -1
- package/dist/presentation/routes/workspaces/create.js +11 -1
- package/dist/presentation/routes/workspaces/create.js.map +1 -1
- package/dist/presentation/routes/workspaces/index.d.ts +2 -0
- package/dist/presentation/routes/workspaces/index.d.ts.map +1 -1
- package/dist/presentation/routes/workspaces/index.js +2 -0
- package/dist/presentation/routes/workspaces/index.js.map +1 -1
- package/dist/presentation/routes/workspaces/slug-availability.d.ts +2 -0
- package/dist/presentation/routes/workspaces/slug-availability.d.ts.map +1 -0
- package/dist/presentation/routes/workspaces/slug-availability.js +31 -0
- package/dist/presentation/routes/workspaces/slug-availability.js.map +1 -0
- package/dist/presentation/routes/workspaces/update.d.ts +2 -0
- package/dist/presentation/routes/workspaces/update.d.ts.map +1 -0
- package/dist/presentation/routes/workspaces/update.js +49 -0
- package/dist/presentation/routes/workspaces/update.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0000_initial.sql +2 -0
- package/drizzle/0003_dapper_talos.sql +13 -0
- package/drizzle/meta/0000_snapshot.json +23 -1
- package/drizzle/meta/0003_snapshot.json +689 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +16 -14
- package/src/core/entities/workspace.ts +1 -0
- package/src/core/errors.ts +7 -0
- package/src/core/index.ts +3 -0
- package/src/core/invitations.ts +1 -1
- package/src/core/rate-limit.test.ts +113 -0
- package/src/core/rate-limit.ts +119 -0
- package/src/core/workspaces.test.ts +9 -0
- package/src/core/workspaces.ts +47 -3
- package/src/db/db.ts +2 -0
- package/src/db/index.ts +10 -0
- package/src/db/memberships.ts +3 -0
- package/src/db/rate-limits.test.ts +74 -0
- package/src/db/rate-limits.ts +59 -0
- package/src/db/schema/rate-limits.ts +27 -0
- package/src/db/schema/workspaces.ts +7 -2
- package/src/db/workspaces.ts +21 -1
- package/src/index.test.ts +2 -0
- package/src/index.ts +1 -0
- package/src/metrics/instance.ts +26 -0
- package/src/presentation/dto/membership.ts +1 -0
- package/src/presentation/dto/workspace.ts +1 -0
- package/src/presentation/e2eRoutes/create-workspace.ts +9 -1
- package/src/presentation/inter-module.test.ts +22 -4
- package/src/presentation/routes/index.ts +8 -1
- package/src/presentation/routes/rate-limit.ts +45 -0
- package/src/presentation/routes/workspaces/create.test.ts +33 -3
- package/src/presentation/routes/workspaces/create.ts +9 -1
- package/src/presentation/routes/workspaces/index.ts +2 -0
- package/src/presentation/routes/workspaces/list.test.ts +1 -0
- package/src/presentation/routes/workspaces/slug-availability.test.ts +159 -0
- package/src/presentation/routes/workspaces/slug-availability.ts +30 -0
- package/src/presentation/routes/workspaces/update.test.ts +114 -0
- package/src/presentation/routes/workspaces/update.ts +41 -0
- package/test/env.ts +1 -0
- package/test/factories/workspace.ts +3 -1
- package/test/globalSetup.ts +1 -1
- package/test/routes.ts +18 -4
- 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": "
|
|
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": "
|
|
28
|
-
"@shipfox/api-auth-
|
|
29
|
-
"@shipfox/api-
|
|
30
|
-
"@shipfox/api-
|
|
31
|
-
"@shipfox/api-runners-dto": "
|
|
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.
|
|
35
|
-
"@shipfox/
|
|
36
|
-
"@shipfox/node-
|
|
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.
|
|
39
|
-
"@shipfox/node-module": "1.0.
|
|
40
|
-
"@shipfox/node-opentelemetry": "0.6.
|
|
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": {
|
package/src/core/errors.ts
CHANGED
|
@@ -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';
|
package/src/core/invitations.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/core/workspaces.ts
CHANGED
|
@@ -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)
|
|
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';
|
package/src/db/memberships.ts
CHANGED
|
@@ -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
|
+
});
|