@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.
- 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.d.ts.map +1 -1
- package/dist/core/invitations.js +7 -4
- 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 +8 -0
- package/dist/core/workspaces.d.ts.map +1 -1
- package/dist/core/workspaces.js +47 -7
- package/dist/core/workspaces.js.map +1 -1
- package/dist/db/db.d.ts +320 -0
- 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/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 +17 -0
- 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/inter-module.d.ts.map +1 -1
- package/dist/presentation/inter-module.js +3 -2
- package/dist/presentation/inter-module.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/invitations/create.d.ts.map +1 -1
- package/dist/presentation/routes/invitations/create.js +5 -1
- package/dist/presentation/routes/invitations/create.js.map +1 -1
- package/dist/presentation/routes/invitations/list.d.ts.map +1 -1
- package/dist/presentation/routes/invitations/list.js +5 -1
- package/dist/presentation/routes/invitations/list.js.map +1 -1
- package/dist/presentation/routes/invitations/revoke.d.ts.map +1 -1
- package/dist/presentation/routes/invitations/revoke.js +5 -1
- package/dist/presentation/routes/invitations/revoke.js.map +1 -1
- package/dist/presentation/routes/members/list.d.ts.map +1 -1
- package/dist/presentation/routes/members/list.js +5 -1
- package/dist/presentation/routes/members/list.js.map +1 -1
- package/dist/presentation/routes/members/remove.d.ts.map +1 -1
- package/dist/presentation/routes/members/remove.js +5 -1
- package/dist/presentation/routes/members/remove.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 +14 -12
- 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.test.ts +8 -8
- package/src/core/invitations.ts +4 -1
- package/src/core/rate-limit.test.ts +113 -0
- package/src/core/rate-limit.ts +119 -0
- package/src/core/workspaces.test.ts +31 -3
- package/src/core/workspaces.ts +51 -4
- 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 +69 -0
- package/src/presentation/inter-module.ts +7 -4
- package/src/presentation/routes/index.ts +8 -1
- package/src/presentation/routes/invitations/create.test.ts +15 -0
- package/src/presentation/routes/invitations/create.ts +2 -1
- package/src/presentation/routes/invitations/list.test.ts +30 -0
- package/src/presentation/routes/invitations/list.ts +2 -1
- package/src/presentation/routes/invitations/revoke.test.ts +14 -0
- package/src/presentation/routes/invitations/revoke.ts +2 -1
- package/src/presentation/routes/members/list.test.ts +29 -1
- package/src/presentation/routes/members/list.ts +2 -1
- package/src/presentation/routes/members/remove.test.ts +14 -0
- package/src/presentation/routes/members/remove.ts +2 -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 +26 -7
- 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.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -24,22 +24,24 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"drizzle-orm": "^0.45.2",
|
|
26
26
|
"zod": "^4.4.3",
|
|
27
|
-
"@shipfox/api-common-dto": "
|
|
28
|
-
"@shipfox/api-auth-
|
|
29
|
-
"@shipfox/api-auth-
|
|
30
|
-
"@shipfox/api-projects-dto": "
|
|
31
|
-
"@shipfox/api-runners-dto": "
|
|
32
|
-
"@shipfox/api-workspaces-dto": "
|
|
27
|
+
"@shipfox/api-common-dto": "12.0.0",
|
|
28
|
+
"@shipfox/api-auth-context": "12.0.0",
|
|
29
|
+
"@shipfox/api-auth-dto": "12.0.0",
|
|
30
|
+
"@shipfox/api-projects-dto": "12.0.0",
|
|
31
|
+
"@shipfox/api-runners-dto": "12.0.0",
|
|
32
|
+
"@shipfox/api-workspaces-dto": "12.0.0",
|
|
33
33
|
"@shipfox/config": "1.2.4",
|
|
34
|
-
"@shipfox/inter-module": "0.2.
|
|
35
|
-
"@shipfox/node-
|
|
36
|
-
"@shipfox/node-
|
|
34
|
+
"@shipfox/inter-module": "0.2.3",
|
|
35
|
+
"@shipfox/node-auth-root-key": "0.2.3",
|
|
36
|
+
"@shipfox/node-drizzle": "0.3.5",
|
|
37
|
+
"@shipfox/node-fastify": "0.4.1",
|
|
37
38
|
"@shipfox/node-email": "0.3.4",
|
|
38
39
|
"@shipfox/node-mailer": "0.2.4",
|
|
39
|
-
"@shipfox/node-module": "1.0.
|
|
40
|
+
"@shipfox/node-module": "1.0.5",
|
|
40
41
|
"@shipfox/node-opentelemetry": "0.6.3",
|
|
41
42
|
"@shipfox/node-outbox": "0.2.6",
|
|
42
|
-
"@shipfox/node-postgres": "0.
|
|
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';
|
|
@@ -105,7 +105,7 @@ describe('invitations core', () => {
|
|
|
105
105
|
workspaceId: workspace.id,
|
|
106
106
|
email,
|
|
107
107
|
invitedByUserId: inviter.userId,
|
|
108
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
108
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
expect(invitation.email).toBe(email);
|
|
@@ -129,7 +129,7 @@ describe('invitations core', () => {
|
|
|
129
129
|
email,
|
|
130
130
|
invitedByUserId: inviter.userId,
|
|
131
131
|
invitedByDisplay: 'Dana Scully',
|
|
132
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
132
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
const payload = await latestInvitationPayload(email);
|
|
@@ -157,7 +157,7 @@ describe('invitations core', () => {
|
|
|
157
157
|
workspaceId: workspace.id,
|
|
158
158
|
email,
|
|
159
159
|
invitedByUserId: inviter.userId,
|
|
160
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
160
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
161
161
|
});
|
|
162
162
|
|
|
163
163
|
const payload = await latestInvitationPayload(email);
|
|
@@ -181,7 +181,7 @@ describe('invitations core', () => {
|
|
|
181
181
|
email,
|
|
182
182
|
invitedByUserId: inviter.userId,
|
|
183
183
|
invitedByDisplay: ' ',
|
|
184
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
184
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
185
185
|
});
|
|
186
186
|
|
|
187
187
|
const payload = await latestInvitationPayload(email);
|
|
@@ -204,14 +204,14 @@ describe('invitations core', () => {
|
|
|
204
204
|
workspaceId: workspace.id,
|
|
205
205
|
email,
|
|
206
206
|
invitedByUserId: inviter.userId,
|
|
207
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
207
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
const promise = createWorkspaceInvitation({
|
|
211
211
|
workspaceId: workspace.id,
|
|
212
212
|
email,
|
|
213
213
|
invitedByUserId: inviter.userId,
|
|
214
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
214
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
215
215
|
});
|
|
216
216
|
|
|
217
217
|
await expect(promise).rejects.toBeInstanceOf(OpenInvitationExistsError);
|
|
@@ -233,7 +233,7 @@ describe('invitations core', () => {
|
|
|
233
233
|
workspaceId: workspace.id,
|
|
234
234
|
email: invitee.email,
|
|
235
235
|
invitedByUserId: inviter.userId,
|
|
236
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
236
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
237
237
|
});
|
|
238
238
|
const token = extractToken((await latestInvitationPayload(invitee.email)).inviteLink);
|
|
239
239
|
|
|
@@ -269,7 +269,7 @@ describe('invitations core', () => {
|
|
|
269
269
|
workspaceId: workspace.id,
|
|
270
270
|
email,
|
|
271
271
|
invitedByUserId: inviter.userId,
|
|
272
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
272
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
273
273
|
});
|
|
274
274
|
const payload = await latestInvitationPayload(email);
|
|
275
275
|
const token = extractToken(payload.inviteLink);
|
package/src/core/invitations.ts
CHANGED
|
@@ -46,6 +46,7 @@ export async function createWorkspaceInvitation(params: {
|
|
|
46
46
|
workspaceId: params.workspaceId,
|
|
47
47
|
userId: params.invitedByUserId,
|
|
48
48
|
memberships: params.invitedByMemberships,
|
|
49
|
+
enforceWorkspaceStatus: false,
|
|
49
50
|
});
|
|
50
51
|
|
|
51
52
|
const rawToken = generateOpaqueToken('invitation');
|
|
@@ -99,7 +100,7 @@ export async function previewInvitation(params: {token: string}): Promise<Previe
|
|
|
99
100
|
|
|
100
101
|
const workspace = await getWorkspaceById(invitation.workspaceId);
|
|
101
102
|
if (!workspace) {
|
|
102
|
-
// Workspace cascade-deleted
|
|
103
|
+
// Workspace cascade-deleted: the invitation row is orphaned. Treat as invalid.
|
|
103
104
|
return {status: 'invalid'};
|
|
104
105
|
}
|
|
105
106
|
|
|
@@ -216,6 +217,7 @@ export async function listWorkspaceInvitations(params: {
|
|
|
216
217
|
workspaceId: params.workspaceId,
|
|
217
218
|
userId: params.requesterUserId,
|
|
218
219
|
memberships: params.requesterMemberships,
|
|
220
|
+
enforceWorkspaceStatus: false,
|
|
219
221
|
});
|
|
220
222
|
|
|
221
223
|
return listOpenInvitationsByWorkspace({workspaceId: params.workspaceId});
|
|
@@ -231,6 +233,7 @@ export async function revokeWorkspaceInvitation(params: {
|
|
|
231
233
|
workspaceId: params.workspaceId,
|
|
232
234
|
userId: params.requesterUserId,
|
|
233
235
|
memberships: params.requesterMemberships,
|
|
236
|
+
enforceWorkspaceStatus: false,
|
|
234
237
|
});
|
|
235
238
|
|
|
236
239
|
const invitation = await findInvitationById({id: params.invitationId});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {eq, sql} from 'drizzle-orm';
|
|
2
|
+
import {db} from '#db/db.js';
|
|
3
|
+
import {workspacesRateLimits} from '#db/schema/rate-limits.js';
|
|
4
|
+
import {
|
|
5
|
+
checkWorkspacesRateLimit,
|
|
6
|
+
hashWorkspacesRateLimitIdentifier,
|
|
7
|
+
WorkspacesRateLimitUnavailableError,
|
|
8
|
+
} from './rate-limit.js';
|
|
9
|
+
|
|
10
|
+
const HMAC_HEX_PATTERN = /^[a-f0-9]{64}$/;
|
|
11
|
+
|
|
12
|
+
describe('checkWorkspacesRateLimit', () => {
|
|
13
|
+
it('hashes identifiers without storing the raw identifier', async () => {
|
|
14
|
+
const identifier = `ip-${crypto.randomUUID()}`;
|
|
15
|
+
const identifierHmac = hashWorkspacesRateLimitIdentifier({
|
|
16
|
+
action: 'slug-availability',
|
|
17
|
+
scope: 'ip',
|
|
18
|
+
identifier,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
await checkWorkspacesRateLimit({
|
|
22
|
+
action: 'slug-availability',
|
|
23
|
+
scope: 'ip',
|
|
24
|
+
identifier,
|
|
25
|
+
limit: 1,
|
|
26
|
+
windowSeconds: 60,
|
|
27
|
+
now: new Date('2026-06-23T00:00:10Z'),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const rows = await db()
|
|
31
|
+
.select({identifierHmac: workspacesRateLimits.identifierHmac})
|
|
32
|
+
.from(workspacesRateLimits)
|
|
33
|
+
.where(eq(workspacesRateLimits.identifierHmac, identifierHmac));
|
|
34
|
+
|
|
35
|
+
expect(identifierHmac).toMatch(HMAC_HEX_PATTERN);
|
|
36
|
+
expect(identifierHmac).not.toContain(identifier);
|
|
37
|
+
expect(rows).toEqual([{identifierHmac}]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects the first over-limit attempt with retry-after seconds', async () => {
|
|
41
|
+
const identifier = `ip-${crypto.randomUUID()}`;
|
|
42
|
+
const now = new Date('2026-06-23T00:00:10Z');
|
|
43
|
+
|
|
44
|
+
await checkWorkspacesRateLimit({
|
|
45
|
+
action: 'slug-availability',
|
|
46
|
+
scope: 'ip',
|
|
47
|
+
identifier,
|
|
48
|
+
limit: 2,
|
|
49
|
+
windowSeconds: 60,
|
|
50
|
+
now,
|
|
51
|
+
});
|
|
52
|
+
await checkWorkspacesRateLimit({
|
|
53
|
+
action: 'slug-availability',
|
|
54
|
+
scope: 'ip',
|
|
55
|
+
identifier,
|
|
56
|
+
limit: 2,
|
|
57
|
+
windowSeconds: 60,
|
|
58
|
+
now,
|
|
59
|
+
});
|
|
60
|
+
const result = checkWorkspacesRateLimit({
|
|
61
|
+
action: 'slug-availability',
|
|
62
|
+
scope: 'ip',
|
|
63
|
+
identifier,
|
|
64
|
+
limit: 2,
|
|
65
|
+
windowSeconds: 60,
|
|
66
|
+
now,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await expect(result).rejects.toMatchObject({
|
|
70
|
+
name: 'WorkspacesRateLimitExceededError',
|
|
71
|
+
retryAfterSeconds: 50,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('fails closed when the limiter query times out', async () => {
|
|
76
|
+
const identifier = `ip-${crypto.randomUUID()}`;
|
|
77
|
+
const identifierHmac = hashWorkspacesRateLimitIdentifier({
|
|
78
|
+
action: 'slug-availability',
|
|
79
|
+
scope: 'ip',
|
|
80
|
+
identifier,
|
|
81
|
+
});
|
|
82
|
+
await db()
|
|
83
|
+
.insert(workspacesRateLimits)
|
|
84
|
+
.values({
|
|
85
|
+
action: 'slug-availability',
|
|
86
|
+
scope: 'ip',
|
|
87
|
+
identifierHmac,
|
|
88
|
+
windowStart: new Date('2026-06-23T00:03:00Z'),
|
|
89
|
+
count: 1,
|
|
90
|
+
expiresAt: new Date('2026-06-23T00:04:00Z'),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await db().transaction(async (tx) => {
|
|
94
|
+
await tx.execute(sql`
|
|
95
|
+
SELECT 1
|
|
96
|
+
FROM workspaces_rate_limits
|
|
97
|
+
WHERE identifier_hmac = ${identifierHmac}
|
|
98
|
+
FOR UPDATE
|
|
99
|
+
`);
|
|
100
|
+
const result = checkWorkspacesRateLimit({
|
|
101
|
+
action: 'slug-availability',
|
|
102
|
+
scope: 'ip',
|
|
103
|
+
identifier,
|
|
104
|
+
limit: 1,
|
|
105
|
+
windowSeconds: 60,
|
|
106
|
+
now: new Date('2026-06-23T00:03:30Z'),
|
|
107
|
+
timeoutMs: 10,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await expect(result).rejects.toBeInstanceOf(WorkspacesRateLimitUnavailableError);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {rateLimitIdentifierKey} from '@shipfox/node-auth-root-key';
|
|
2
|
+
import {
|
|
3
|
+
checkRateLimit,
|
|
4
|
+
hashRateLimitIdentifier,
|
|
5
|
+
RateLimitExceededError,
|
|
6
|
+
type RateLimitOutcome,
|
|
7
|
+
type RateLimitPolicy,
|
|
8
|
+
RateLimitUnavailableError,
|
|
9
|
+
} from '@shipfox/node-rate-limit';
|
|
10
|
+
import {consumeWorkspacesRateLimit, pruneExpiredWorkspacesRateLimits} from '#db/rate-limits.js';
|
|
11
|
+
import {
|
|
12
|
+
recordWorkspaceRateLimitCheck,
|
|
13
|
+
recordWorkspaceRateLimitPruneFailure,
|
|
14
|
+
} from '#metrics/instance.js';
|
|
15
|
+
|
|
16
|
+
export type WorkspacesRateLimitAction = 'slug-availability';
|
|
17
|
+
export type WorkspacesRateLimitScope = 'ip';
|
|
18
|
+
export type WorkspacesRateLimitOutcome = RateLimitOutcome;
|
|
19
|
+
export type WorkspacesRateLimitPolicy = RateLimitPolicy;
|
|
20
|
+
|
|
21
|
+
export interface CheckWorkspacesRateLimitParams extends WorkspacesRateLimitPolicy {
|
|
22
|
+
action: WorkspacesRateLimitAction;
|
|
23
|
+
scope: WorkspacesRateLimitScope;
|
|
24
|
+
identifier: string;
|
|
25
|
+
now?: Date | undefined;
|
|
26
|
+
timeoutMs?: number | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class WorkspacesRateLimitExceededError extends RateLimitExceededError<
|
|
30
|
+
WorkspacesRateLimitAction,
|
|
31
|
+
WorkspacesRateLimitScope
|
|
32
|
+
> {
|
|
33
|
+
constructor(params: {
|
|
34
|
+
action: WorkspacesRateLimitAction;
|
|
35
|
+
scope: WorkspacesRateLimitScope;
|
|
36
|
+
retryAfterSeconds: number;
|
|
37
|
+
identifierHmacPrefix: string;
|
|
38
|
+
}) {
|
|
39
|
+
super(params);
|
|
40
|
+
this.name = 'WorkspacesRateLimitExceededError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class WorkspacesRateLimitUnavailableError extends RateLimitUnavailableError<
|
|
45
|
+
WorkspacesRateLimitAction,
|
|
46
|
+
WorkspacesRateLimitScope
|
|
47
|
+
> {
|
|
48
|
+
constructor(params: {
|
|
49
|
+
action: WorkspacesRateLimitAction;
|
|
50
|
+
scope: WorkspacesRateLimitScope;
|
|
51
|
+
identifierHmacPrefix: string;
|
|
52
|
+
cause: unknown;
|
|
53
|
+
}) {
|
|
54
|
+
super(params);
|
|
55
|
+
this.name = 'WorkspacesRateLimitUnavailableError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const WORKSPACE_SLUG_AVAILABILITY_RATE_LIMIT = {
|
|
60
|
+
limit: 60,
|
|
61
|
+
windowSeconds: 5 * 60,
|
|
62
|
+
} as const;
|
|
63
|
+
|
|
64
|
+
const RATE_LIMIT_TIMEOUT_MS = 250;
|
|
65
|
+
const IDENTIFIER_HASH_DOMAIN = 'shipfox.workspaces.rate-limit.identifier.v1';
|
|
66
|
+
|
|
67
|
+
export function hashWorkspacesRateLimitIdentifier(params: {
|
|
68
|
+
action: WorkspacesRateLimitAction;
|
|
69
|
+
scope: WorkspacesRateLimitScope;
|
|
70
|
+
identifier: string;
|
|
71
|
+
}): string {
|
|
72
|
+
return hashRateLimitIdentifier({
|
|
73
|
+
action: params.action,
|
|
74
|
+
scope: params.scope,
|
|
75
|
+
identifier: params.identifier,
|
|
76
|
+
secret: rateLimitIdentifierKey(),
|
|
77
|
+
domain: IDENTIFIER_HASH_DOMAIN,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function checkWorkspacesRateLimit(
|
|
82
|
+
params: CheckWorkspacesRateLimitParams,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
try {
|
|
85
|
+
await checkRateLimit({
|
|
86
|
+
action: params.action,
|
|
87
|
+
scope: params.scope,
|
|
88
|
+
identifier: params.identifier,
|
|
89
|
+
limit: params.limit,
|
|
90
|
+
windowSeconds: params.windowSeconds,
|
|
91
|
+
identifierSecret: rateLimitIdentifierKey(),
|
|
92
|
+
identifierHashDomain: IDENTIFIER_HASH_DOMAIN,
|
|
93
|
+
consume: consumeWorkspacesRateLimit,
|
|
94
|
+
prune: pruneExpiredWorkspacesRateLimits,
|
|
95
|
+
onCheck: recordWorkspaceRateLimitCheck,
|
|
96
|
+
onPruneFailure: recordWorkspaceRateLimitPruneFailure,
|
|
97
|
+
now: params.now,
|
|
98
|
+
timeoutMs: params.timeoutMs ?? RATE_LIMIT_TIMEOUT_MS,
|
|
99
|
+
});
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof RateLimitExceededError) {
|
|
102
|
+
throw new WorkspacesRateLimitExceededError({
|
|
103
|
+
action: error.action,
|
|
104
|
+
scope: error.scope,
|
|
105
|
+
retryAfterSeconds: error.retryAfterSeconds,
|
|
106
|
+
identifierHmacPrefix: error.identifierHmacPrefix,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (error instanceof RateLimitUnavailableError) {
|
|
110
|
+
throw new WorkspacesRateLimitUnavailableError({
|
|
111
|
+
action: error.action,
|
|
112
|
+
scope: error.scope,
|
|
113
|
+
identifierHmacPrefix: error.identifierHmacPrefix,
|
|
114
|
+
cause: error.cause,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -5,7 +5,7 @@ import {findMembership} from '#db/memberships.js';
|
|
|
5
5
|
import {workspacesOutbox} from '#db/schema/outbox.js';
|
|
6
6
|
import {updateWorkspace} from '#db/workspaces.js';
|
|
7
7
|
import {userFactory} from '#test/index.js';
|
|
8
|
-
import {MembershipRequiredError, WorkspaceNotFoundError} from './errors.js';
|
|
8
|
+
import {MembershipRequiredError, WorkspaceInactiveError, WorkspaceNotFoundError} from './errors.js';
|
|
9
9
|
import {
|
|
10
10
|
createWorkspaceForUser,
|
|
11
11
|
getWorkspaceOperatingState,
|
|
@@ -15,9 +15,11 @@ import {
|
|
|
15
15
|
describe('workspaces core', () => {
|
|
16
16
|
test('createWorkspaceForUser creates a workspace and membership for the user', async () => {
|
|
17
17
|
const user = userFactory.build();
|
|
18
|
+
const slug = `core-${crypto.randomUUID().slice(0, 8)}`;
|
|
18
19
|
|
|
19
20
|
const workspace = await createWorkspaceForUser({
|
|
20
21
|
name: 'Core Workspace',
|
|
22
|
+
slug,
|
|
21
23
|
userId: user.userId,
|
|
22
24
|
userEmail: user.email,
|
|
23
25
|
userName: user.name,
|
|
@@ -25,6 +27,7 @@ describe('workspaces core', () => {
|
|
|
25
27
|
const membership = await findMembership({userId: user.userId, workspaceId: workspace.id});
|
|
26
28
|
|
|
27
29
|
expect(workspace.name).toBe('Core Workspace');
|
|
30
|
+
expect(workspace.slug).toBe(slug);
|
|
28
31
|
expect(membership).toBeDefined();
|
|
29
32
|
});
|
|
30
33
|
|
|
@@ -33,6 +36,7 @@ describe('workspaces core', () => {
|
|
|
33
36
|
|
|
34
37
|
const workspace = await createWorkspaceForUser({
|
|
35
38
|
name: 'Evented Workspace',
|
|
39
|
+
slug: `evented-${crypto.randomUUID().slice(0, 8)}`,
|
|
36
40
|
userId: user.userId,
|
|
37
41
|
userEmail: user.email,
|
|
38
42
|
userName: user.name,
|
|
@@ -47,6 +51,7 @@ describe('workspaces core', () => {
|
|
|
47
51
|
payload: {
|
|
48
52
|
workspaceId: workspace.id,
|
|
49
53
|
name: workspace.name,
|
|
54
|
+
slug: workspace.slug,
|
|
50
55
|
creatorUserId: user.userId,
|
|
51
56
|
},
|
|
52
57
|
});
|
|
@@ -56,6 +61,7 @@ describe('workspaces core', () => {
|
|
|
56
61
|
const user = userFactory.build();
|
|
57
62
|
const workspace = await createWorkspaceForUser({
|
|
58
63
|
name: 'Member Workspace',
|
|
64
|
+
slug: `member-${crypto.randomUUID().slice(0, 8)}`,
|
|
59
65
|
userId: user.userId,
|
|
60
66
|
userEmail: user.email,
|
|
61
67
|
userName: user.name,
|
|
@@ -64,7 +70,7 @@ describe('workspaces core', () => {
|
|
|
64
70
|
const result = await requireWorkspaceMembership({
|
|
65
71
|
workspaceId: workspace.id,
|
|
66
72
|
userId: user.userId,
|
|
67
|
-
memberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
73
|
+
memberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
68
74
|
});
|
|
69
75
|
|
|
70
76
|
expect(result.workspace.id).toBe(workspace.id);
|
|
@@ -75,6 +81,7 @@ describe('workspaces core', () => {
|
|
|
75
81
|
test('getWorkspaceOperatingState returns the current status without workspace details', async () => {
|
|
76
82
|
const workspace = await createWorkspaceForUser({
|
|
77
83
|
name: 'Operating State Workspace',
|
|
84
|
+
slug: `operating-${crypto.randomUUID().slice(0, 8)}`,
|
|
78
85
|
userId: crypto.randomUUID(),
|
|
79
86
|
});
|
|
80
87
|
|
|
@@ -85,11 +92,32 @@ describe('workspaces core', () => {
|
|
|
85
92
|
expect(await getWorkspaceOperatingState({workspaceId: workspace.id})).toBe('suspended');
|
|
86
93
|
});
|
|
87
94
|
|
|
95
|
+
test('requireWorkspaceMembership rejects a suspended workspace', async () => {
|
|
96
|
+
const user = userFactory.build();
|
|
97
|
+
const workspace = await createWorkspaceForUser({
|
|
98
|
+
name: 'Suspended Workspace',
|
|
99
|
+
slug: `suspended-${crypto.randomUUID().slice(0, 8)}`,
|
|
100
|
+
userId: user.userId,
|
|
101
|
+
userEmail: user.email,
|
|
102
|
+
userName: user.name,
|
|
103
|
+
});
|
|
104
|
+
await updateWorkspace({id: workspace.id, status: 'suspended'});
|
|
105
|
+
|
|
106
|
+
const membership = requireWorkspaceMembership({
|
|
107
|
+
workspaceId: workspace.id,
|
|
108
|
+
userId: user.userId,
|
|
109
|
+
memberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await expect(membership).rejects.toBeInstanceOf(WorkspaceInactiveError);
|
|
113
|
+
});
|
|
114
|
+
|
|
88
115
|
test('requireWorkspaceMembership rejects when memberships do not include the workspace', async () => {
|
|
89
116
|
const owner = userFactory.build();
|
|
90
117
|
const outsider = userFactory.build();
|
|
91
118
|
const workspace = await createWorkspaceForUser({
|
|
92
119
|
name: 'Private Workspace',
|
|
120
|
+
slug: `private-${crypto.randomUUID().slice(0, 8)}`,
|
|
93
121
|
userId: owner.userId,
|
|
94
122
|
userEmail: owner.email,
|
|
95
123
|
userName: owner.name,
|
|
@@ -110,7 +138,7 @@ describe('workspaces core', () => {
|
|
|
110
138
|
const missingWorkspace = requireWorkspaceMembership({
|
|
111
139
|
workspaceId: ghostId,
|
|
112
140
|
userId: owner.userId,
|
|
113
|
-
memberships: [{workspaceId: ghostId, role: 'admin'}],
|
|
141
|
+
memberships: [{workspaceId: ghostId, role: 'admin', workspaceStatus: 'active'}],
|
|
114
142
|
});
|
|
115
143
|
|
|
116
144
|
await expect(missingWorkspace).rejects.toBeInstanceOf(WorkspaceNotFoundError);
|
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,12 +27,14 @@ import {
|
|
|
25
27
|
SelfRemovalNotAllowedError,
|
|
26
28
|
WorkspaceInactiveError,
|
|
27
29
|
WorkspaceNotFoundError,
|
|
30
|
+
WorkspaceSlugConflictError,
|
|
28
31
|
} from './errors.js';
|
|
29
32
|
|
|
30
33
|
export interface RequireWorkspaceMembershipParams {
|
|
31
34
|
workspaceId: string;
|
|
32
35
|
userId: string;
|
|
33
36
|
memberships: ReadonlyArray<UserContextMembership>;
|
|
37
|
+
enforceWorkspaceStatus?: boolean;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
export interface RequireWorkspaceMembershipResult {
|
|
@@ -52,7 +56,7 @@ export async function requireWorkspaceMembership(
|
|
|
52
56
|
if (!workspace) {
|
|
53
57
|
throw new WorkspaceNotFoundError(params.workspaceId);
|
|
54
58
|
}
|
|
55
|
-
if (workspace.status !== 'active') {
|
|
59
|
+
if (params.enforceWorkspaceStatus !== false && workspace.status !== 'active') {
|
|
56
60
|
throw new WorkspaceInactiveError(params.workspaceId);
|
|
57
61
|
}
|
|
58
62
|
|
|
@@ -61,6 +65,7 @@ export async function requireWorkspaceMembership(
|
|
|
61
65
|
|
|
62
66
|
export async function createWorkspaceForUser(params: {
|
|
63
67
|
name: string;
|
|
68
|
+
slug: string;
|
|
64
69
|
userId: string;
|
|
65
70
|
userEmail?: string | undefined;
|
|
66
71
|
userName?: string | null | undefined;
|
|
@@ -68,9 +73,12 @@ export async function createWorkspaceForUser(params: {
|
|
|
68
73
|
return await db().transaction(async (tx) => {
|
|
69
74
|
const [workspaceRow] = await tx
|
|
70
75
|
.insert(workspaces)
|
|
71
|
-
.values({name: params.name, createdBy: params.userId})
|
|
76
|
+
.values({name: params.name, slug: params.slug, createdBy: params.userId})
|
|
77
|
+
.onConflictDoNothing({target: workspaces.slug})
|
|
72
78
|
.returning();
|
|
73
|
-
if (!workspaceRow)
|
|
79
|
+
if (!workspaceRow) {
|
|
80
|
+
throw new WorkspaceSlugConflictError(params.slug);
|
|
81
|
+
}
|
|
74
82
|
await tx.insert(memberships).values({
|
|
75
83
|
userId: params.userId,
|
|
76
84
|
userEmail: params.userEmail ?? `user-${params.userId}@example.local`,
|
|
@@ -84,6 +92,7 @@ export async function createWorkspaceForUser(params: {
|
|
|
84
92
|
payload: {
|
|
85
93
|
workspaceId: workspace.id,
|
|
86
94
|
name: workspace.name,
|
|
95
|
+
slug: workspace.slug,
|
|
87
96
|
creatorUserId: params.userId,
|
|
88
97
|
},
|
|
89
98
|
});
|
|
@@ -92,6 +101,42 @@ export async function createWorkspaceForUser(params: {
|
|
|
92
101
|
});
|
|
93
102
|
}
|
|
94
103
|
|
|
104
|
+
export async function checkWorkspaceSlugAvailability(slug: string): Promise<boolean> {
|
|
105
|
+
return await isWorkspaceSlugAvailable(slug);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function updateWorkspaceDetails(params: {
|
|
109
|
+
workspaceId: string;
|
|
110
|
+
name?: string | undefined;
|
|
111
|
+
slug?: string | undefined;
|
|
112
|
+
}): Promise<Workspace> {
|
|
113
|
+
try {
|
|
114
|
+
return await db().transaction(async (tx) => {
|
|
115
|
+
const workspace = await updateWorkspace(
|
|
116
|
+
{id: params.workspaceId, name: params.name, slug: params.slug},
|
|
117
|
+
{tx},
|
|
118
|
+
);
|
|
119
|
+
if (!workspace) throw new WorkspaceNotFoundError(params.workspaceId);
|
|
120
|
+
|
|
121
|
+
await writeOutboxEvent<WorkspacesEventMap>(tx, workspacesOutbox, {
|
|
122
|
+
type: WORKSPACES_WORKSPACE_UPDATED,
|
|
123
|
+
payload: {
|
|
124
|
+
workspaceId: workspace.id,
|
|
125
|
+
name: workspace.name,
|
|
126
|
+
slug: workspace.slug,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return workspace;
|
|
131
|
+
});
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (isUniqueViolation(error, 'workspaces_slug_unique') && params.slug) {
|
|
134
|
+
throw new WorkspaceSlugConflictError(params.slug);
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
95
140
|
export async function getWorkspace(params: {workspaceId: string}): Promise<Workspace> {
|
|
96
141
|
const workspace = await getWorkspaceById(params.workspaceId);
|
|
97
142
|
if (!workspace) {
|
|
@@ -128,6 +173,7 @@ export async function listWorkspaceMembers(params: {
|
|
|
128
173
|
workspaceId: params.workspaceId,
|
|
129
174
|
userId: params.requesterUserId,
|
|
130
175
|
memberships: params.requesterMemberships,
|
|
176
|
+
enforceWorkspaceStatus: false,
|
|
131
177
|
});
|
|
132
178
|
|
|
133
179
|
return listMembershipsByWorkspace({workspaceId: params.workspaceId});
|
|
@@ -143,6 +189,7 @@ export async function removeWorkspaceMember(params: {
|
|
|
143
189
|
workspaceId: params.workspaceId,
|
|
144
190
|
userId: params.requesterUserId,
|
|
145
191
|
memberships: params.requesterMemberships,
|
|
192
|
+
enforceWorkspaceStatus: false,
|
|
146
193
|
});
|
|
147
194
|
|
|
148
195
|
if (params.userId === params.requesterUserId) {
|
package/src/db/db.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {workspacesAdminCommandResults} from './schema/admin-command-results.js';
|
|
|
4
4
|
import {invitations} from './schema/invitations.js';
|
|
5
5
|
import {memberships} from './schema/memberships.js';
|
|
6
6
|
import {workspacesOutbox} from './schema/outbox.js';
|
|
7
|
+
import {workspacesRateLimits} from './schema/rate-limits.js';
|
|
7
8
|
import {workspaces} from './schema/workspaces.js';
|
|
8
9
|
|
|
9
10
|
export const schema = {
|
|
@@ -12,6 +13,7 @@ export const schema = {
|
|
|
12
13
|
memberships,
|
|
13
14
|
invitations,
|
|
14
15
|
workspacesOutbox,
|
|
16
|
+
workspacesRateLimits,
|
|
15
17
|
};
|
|
16
18
|
|
|
17
19
|
let _db: NodePgDatabase<typeof schema> | undefined;
|