@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/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
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConsumeRateLimitParams,
|
|
3
|
+
type ConsumeRateLimitResult,
|
|
4
|
+
createRateLimitPersistence,
|
|
5
|
+
} from '@shipfox/node-rate-limit';
|
|
6
|
+
import {lt, sql} from 'drizzle-orm';
|
|
7
|
+
import {db} from './db.js';
|
|
8
|
+
import {workspacesRateLimits} from './schema/rate-limits.js';
|
|
9
|
+
|
|
10
|
+
type WorkspacesRateLimitTransaction = Parameters<
|
|
11
|
+
Parameters<ReturnType<typeof db>['transaction']>[0]
|
|
12
|
+
>[0];
|
|
13
|
+
|
|
14
|
+
const persistence = createRateLimitPersistence<WorkspacesRateLimitTransaction>({
|
|
15
|
+
transaction: (callback) => db().transaction(callback),
|
|
16
|
+
setStatementTimeout: async (transaction, timeoutMs) => {
|
|
17
|
+
await transaction.execute(
|
|
18
|
+
sql`select set_config('statement_timeout', ${`${timeoutMs}ms`}, true)`,
|
|
19
|
+
);
|
|
20
|
+
},
|
|
21
|
+
consume: async (transaction, params) => {
|
|
22
|
+
const rows = await transaction
|
|
23
|
+
.insert(workspacesRateLimits)
|
|
24
|
+
.values({
|
|
25
|
+
action: params.action,
|
|
26
|
+
scope: params.scope,
|
|
27
|
+
identifierHmac: params.identifierHmac,
|
|
28
|
+
windowStart: params.windowStart,
|
|
29
|
+
count: 1,
|
|
30
|
+
expiresAt: params.expiresAt,
|
|
31
|
+
})
|
|
32
|
+
.onConflictDoUpdate({
|
|
33
|
+
target: [
|
|
34
|
+
workspacesRateLimits.action,
|
|
35
|
+
workspacesRateLimits.scope,
|
|
36
|
+
workspacesRateLimits.identifierHmac,
|
|
37
|
+
workspacesRateLimits.windowStart,
|
|
38
|
+
],
|
|
39
|
+
set: {
|
|
40
|
+
count: sql`${workspacesRateLimits.count} + 1`,
|
|
41
|
+
updatedAt: sql`now()`,
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
.returning({count: workspacesRateLimits.count, expiresAt: workspacesRateLimits.expiresAt});
|
|
45
|
+
|
|
46
|
+
return rows[0];
|
|
47
|
+
},
|
|
48
|
+
prune: async (now) => {
|
|
49
|
+
const result = await db()
|
|
50
|
+
.delete(workspacesRateLimits)
|
|
51
|
+
.where(lt(workspacesRateLimits.expiresAt, now));
|
|
52
|
+
return result.rowCount ?? 0;
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export type ConsumeWorkspacesRateLimitParams = ConsumeRateLimitParams<string, string>;
|
|
57
|
+
export type ConsumeWorkspacesRateLimitResult = ConsumeRateLimitResult;
|
|
58
|
+
export const consumeWorkspacesRateLimit = persistence.consume;
|
|
59
|
+
export const pruneExpiredWorkspacesRateLimits = persistence.prune;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {index, integer, text, timestamp, uniqueIndex} from 'drizzle-orm/pg-core';
|
|
2
|
+
import {pgTable} from './common.js';
|
|
3
|
+
|
|
4
|
+
export const workspacesRateLimits = pgTable(
|
|
5
|
+
'rate_limits',
|
|
6
|
+
{
|
|
7
|
+
action: text('action').notNull(),
|
|
8
|
+
scope: text('scope').notNull(),
|
|
9
|
+
identifierHmac: text('identifier_hmac').notNull(),
|
|
10
|
+
windowStart: timestamp('window_start', {withTimezone: true}).notNull(),
|
|
11
|
+
count: integer('count').notNull().default(1),
|
|
12
|
+
expiresAt: timestamp('expires_at', {withTimezone: true}).notNull(),
|
|
13
|
+
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
14
|
+
updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
|
|
15
|
+
},
|
|
16
|
+
(table) => [
|
|
17
|
+
uniqueIndex('workspaces_rate_limits_window_unique').on(
|
|
18
|
+
table.action,
|
|
19
|
+
table.scope,
|
|
20
|
+
table.identifierHmac,
|
|
21
|
+
table.windowStart,
|
|
22
|
+
),
|
|
23
|
+
index('workspaces_rate_limits_expires_at_idx').on(table.expiresAt),
|
|
24
|
+
],
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export type WorkspaceRateLimitDb = typeof workspacesRateLimits.$inferSelect;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
|
-
import {index, jsonb, pgEnum, text, timestamp, uuid} from 'drizzle-orm/pg-core';
|
|
2
|
+
import {index, jsonb, pgEnum, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
|
|
3
3
|
import type {Workspace, WorkspaceStatus} from '#core/entities/workspace.js';
|
|
4
4
|
import {pgTable} from './common.js';
|
|
5
5
|
|
|
@@ -14,13 +14,17 @@ export const workspaces = pgTable(
|
|
|
14
14
|
{
|
|
15
15
|
id: uuidv7PrimaryKey(),
|
|
16
16
|
name: text('name').notNull(),
|
|
17
|
+
slug: text('slug').notNull(),
|
|
17
18
|
status: workspaceStatusEnum('status').notNull().default('active'),
|
|
18
19
|
settings: jsonb('settings').notNull().default({}),
|
|
19
20
|
createdBy: uuid('created_by'),
|
|
20
21
|
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
21
22
|
updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
|
|
22
23
|
},
|
|
23
|
-
(table) => [
|
|
24
|
+
(table) => [
|
|
25
|
+
index('workspaces_name_id_idx').on(table.name, table.id),
|
|
26
|
+
uniqueIndex('workspaces_slug_unique').on(table.slug),
|
|
27
|
+
],
|
|
24
28
|
);
|
|
25
29
|
|
|
26
30
|
export type WorkspaceDb = typeof workspaces.$inferSelect;
|
|
@@ -30,6 +34,7 @@ export function toWorkspace(row: WorkspaceDb): Workspace {
|
|
|
30
34
|
return {
|
|
31
35
|
id: row.id,
|
|
32
36
|
name: row.name,
|
|
37
|
+
slug: row.slug,
|
|
33
38
|
status: row.status as WorkspaceStatus,
|
|
34
39
|
settings: row.settings as Record<string, unknown>,
|
|
35
40
|
createdBy: row.createdBy,
|
package/src/db/workspaces.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {toWorkspace, workspaces} from './schema/workspaces.js';
|
|
|
9
9
|
|
|
10
10
|
export interface CreateWorkspaceParams {
|
|
11
11
|
name: string;
|
|
12
|
+
/** Existing database-only fixtures predate the required API slug. */
|
|
13
|
+
slug?: string | undefined;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export async function createWorkspace(params: CreateWorkspaceParams): Promise<Workspace> {
|
|
@@ -16,6 +18,7 @@ export async function createWorkspace(params: CreateWorkspaceParams): Promise<Wo
|
|
|
16
18
|
.insert(workspaces)
|
|
17
19
|
.values({
|
|
18
20
|
name: params.name,
|
|
21
|
+
slug: params.slug ?? `workspace-${crypto.randomUUID().slice(0, 8)}`,
|
|
19
22
|
})
|
|
20
23
|
.returning();
|
|
21
24
|
|
|
@@ -28,19 +31,26 @@ export async function createWorkspace(params: CreateWorkspaceParams): Promise<Wo
|
|
|
28
31
|
export interface UpdateWorkspaceParams {
|
|
29
32
|
id: string;
|
|
30
33
|
name?: string | undefined;
|
|
34
|
+
slug?: string | undefined;
|
|
31
35
|
status?: WorkspaceStatus | undefined;
|
|
32
36
|
settings?: Record<string, unknown> | undefined;
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
type WorkspaceDatabase = ReturnType<typeof db>;
|
|
40
|
+
type WorkspaceTransaction = Parameters<Parameters<WorkspaceDatabase['transaction']>[0]>[0];
|
|
41
|
+
|
|
35
42
|
export async function updateWorkspace(
|
|
36
43
|
params: UpdateWorkspaceParams,
|
|
44
|
+
options: {tx?: WorkspaceDatabase | WorkspaceTransaction | undefined} = {},
|
|
37
45
|
): Promise<Workspace | undefined> {
|
|
46
|
+
const executor = options.tx ?? db();
|
|
38
47
|
const set: Record<string, unknown> = {updatedAt: sql`NOW()`};
|
|
39
48
|
if (params.name !== undefined) set.name = params.name;
|
|
49
|
+
if (params.slug !== undefined) set.slug = params.slug;
|
|
40
50
|
if (params.status !== undefined) set.status = params.status;
|
|
41
51
|
if (params.settings !== undefined) set.settings = params.settings;
|
|
42
52
|
|
|
43
|
-
const rows = await
|
|
53
|
+
const rows = await executor
|
|
44
54
|
.update(workspaces)
|
|
45
55
|
.set(set)
|
|
46
56
|
.where(eq(workspaces.id, params.id))
|
|
@@ -58,6 +68,16 @@ export async function getWorkspaceById(id: string): Promise<Workspace | undefine
|
|
|
58
68
|
return toWorkspace(row);
|
|
59
69
|
}
|
|
60
70
|
|
|
71
|
+
export async function isWorkspaceSlugAvailable(slug: string): Promise<boolean> {
|
|
72
|
+
const rows = await db()
|
|
73
|
+
.select({id: workspaces.id})
|
|
74
|
+
.from(workspaces)
|
|
75
|
+
.where(eq(workspaces.slug, slug))
|
|
76
|
+
.limit(1);
|
|
77
|
+
|
|
78
|
+
return rows.length === 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
61
81
|
export interface AdminWorkspaceRow extends Workspace {
|
|
62
82
|
memberCount: number;
|
|
63
83
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
WORKSPACES_MEMBER_INVITED,
|
|
5
5
|
WORKSPACES_MEMBER_JOINED,
|
|
6
6
|
WORKSPACES_WORKSPACE_CREATED,
|
|
7
|
+
WORKSPACES_WORKSPACE_UPDATED,
|
|
7
8
|
workspacesEventSchemas,
|
|
8
9
|
} from '@shipfox/api-workspaces-dto';
|
|
9
10
|
import {workspacesModule} from './index.js';
|
|
@@ -27,6 +28,7 @@ describe('workspacesModule', () => {
|
|
|
27
28
|
expect(Object.keys(publisher?.eventSchemas ?? {})).toEqual([
|
|
28
29
|
WORKSPACES_INVITATION_SEND_REQUESTED,
|
|
29
30
|
WORKSPACES_WORKSPACE_CREATED,
|
|
31
|
+
WORKSPACES_WORKSPACE_UPDATED,
|
|
30
32
|
WORKSPACES_MEMBER_INVITED,
|
|
31
33
|
WORKSPACES_MEMBER_JOINED,
|
|
32
34
|
ADMINISTRATION_ACTION_PERFORMED,
|
package/src/index.ts
CHANGED
package/src/metrics/instance.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {instanceMetrics} from '@shipfox/node-opentelemetry';
|
|
|
3
3
|
export type WorkspacesInvitationEmailRequested = 'requested' | 'skipped';
|
|
4
4
|
export type WorkspacesMembershipChangeAction = 'added' | 'removed';
|
|
5
5
|
export type WorkspacesInvitationAcceptOutcome = 'added' | 'already_member';
|
|
6
|
+
export type WorkspacesRateLimitOutcome = 'allowed' | 'blocked' | 'unavailable';
|
|
6
7
|
|
|
7
8
|
const meter = instanceMetrics.getMeter('workspaces');
|
|
8
9
|
|
|
@@ -28,6 +29,20 @@ const invitationAcceptedCount = meter.createCounter<{
|
|
|
28
29
|
description: 'Workspace invitations accepted by membership outcome',
|
|
29
30
|
});
|
|
30
31
|
|
|
32
|
+
const workspaceRateLimitCheckCount = meter.createCounter<{
|
|
33
|
+
action: 'slug-availability';
|
|
34
|
+
outcome: WorkspacesRateLimitOutcome;
|
|
35
|
+
}>('workspaces_rate_limit_checks', {
|
|
36
|
+
description: 'Workspace rate-limit checks by action and outcome',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const workspaceRateLimitPruneFailureCount = meter.createCounter(
|
|
40
|
+
'workspaces_rate_limit_prune_failures',
|
|
41
|
+
{
|
|
42
|
+
description: 'Workspace rate-limit prune failures',
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
31
46
|
function recordMetric(record: () => void): void {
|
|
32
47
|
try {
|
|
33
48
|
record();
|
|
@@ -55,3 +70,14 @@ export function recordWorkspaceInvitationAccepted(
|
|
|
55
70
|
): void {
|
|
56
71
|
recordMetric(() => invitationAcceptedCount.add(1, {outcome}));
|
|
57
72
|
}
|
|
73
|
+
|
|
74
|
+
export function recordWorkspaceRateLimitCheck(params: {
|
|
75
|
+
action: 'slug-availability';
|
|
76
|
+
outcome: WorkspacesRateLimitOutcome;
|
|
77
|
+
}): void {
|
|
78
|
+
recordMetric(() => workspaceRateLimitCheckCount.add(1, params));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function recordWorkspaceRateLimitPruneFailure(): void {
|
|
82
|
+
recordMetric(() => workspaceRateLimitPruneFailureCount.add(1));
|
|
83
|
+
}
|
|
@@ -9,6 +9,7 @@ export function toMembershipWithWorkspaceDto(
|
|
|
9
9
|
user_id: membership.userId,
|
|
10
10
|
workspace_id: membership.workspaceId,
|
|
11
11
|
workspace_name: membership.workspaceName,
|
|
12
|
+
workspace_slug: membership.workspaceSlug,
|
|
12
13
|
workspace_status: membership.workspaceStatus,
|
|
13
14
|
created_at: membership.createdAt.toISOString(),
|
|
14
15
|
updated_at: membership.updatedAt.toISOString(),
|
|
@@ -2,7 +2,8 @@ import {
|
|
|
2
2
|
e2eCreateWorkspaceBodySchema,
|
|
3
3
|
e2eCreateWorkspaceResponseSchema,
|
|
4
4
|
} from '@shipfox/api-workspaces-dto';
|
|
5
|
-
import {defineRoute} from '@shipfox/node-fastify';
|
|
5
|
+
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
6
|
+
import {WorkspaceSlugConflictError} from '#core/errors.js';
|
|
6
7
|
import {createWorkspaceForUser} from '#core/index.js';
|
|
7
8
|
import {toWorkspaceDto} from '#presentation/dto/index.js';
|
|
8
9
|
|
|
@@ -16,9 +17,16 @@ export const createE2eWorkspaceRoute = defineRoute({
|
|
|
16
17
|
201: e2eCreateWorkspaceResponseSchema,
|
|
17
18
|
},
|
|
18
19
|
},
|
|
20
|
+
errorHandler: (error) => {
|
|
21
|
+
if (error instanceof WorkspaceSlugConflictError) {
|
|
22
|
+
throw new ClientError('Workspace slug is already taken', 'slug-conflict', {status: 409});
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
},
|
|
19
26
|
handler: async (request, reply) => {
|
|
20
27
|
const workspace = await createWorkspaceForUser({
|
|
21
28
|
name: request.body.name,
|
|
29
|
+
slug: request.body.slug ?? `e2e-workspace-${crypto.randomUUID().slice(0, 8)}`,
|
|
22
30
|
userId: request.body.user_id,
|
|
23
31
|
userEmail: request.body.user_email,
|
|
24
32
|
userName: request.body.user_name,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {buildUserContext, requireWorkspaceAccess, setUserContext} from '@shipfox/api-auth-context';
|
|
1
2
|
import {workspacesInterModuleContract} from '@shipfox/api-workspaces-dto/inter-module';
|
|
2
3
|
import {isInterModuleKnownError} from '@shipfox/inter-module';
|
|
3
4
|
import {createInMemoryInterModuleTransport} from '@shipfox/node-module/inter-module';
|
|
@@ -20,6 +21,7 @@ describe('Workspaces inter-module presentation', () => {
|
|
|
20
21
|
const creatorUserId = crypto.randomUUID();
|
|
21
22
|
const workspace = await createWorkspaceForUser({
|
|
22
23
|
name: 'Creator Workspace',
|
|
24
|
+
slug: `creator-${crypto.randomUUID().slice(0, 8)}`,
|
|
23
25
|
userId: creatorUserId,
|
|
24
26
|
});
|
|
25
27
|
await createMembership({workspaceId: workspace.id, userId: crypto.randomUUID()});
|
|
@@ -34,6 +36,7 @@ describe('Workspaces inter-module presentation', () => {
|
|
|
34
36
|
const creatorUserId = crypto.randomUUID();
|
|
35
37
|
const workspace = await createWorkspaceForUser({
|
|
36
38
|
name: 'Outlasted Creator Workspace',
|
|
39
|
+
slug: `outlasted-${crypto.randomUUID().slice(0, 8)}`,
|
|
37
40
|
userId: creatorUserId,
|
|
38
41
|
});
|
|
39
42
|
const remainingUserId = crypto.randomUUID();
|
|
@@ -54,6 +57,72 @@ describe('Workspaces inter-module presentation', () => {
|
|
|
54
57
|
expect(result).toEqual({creatorUserId: null});
|
|
55
58
|
});
|
|
56
59
|
|
|
60
|
+
test('carries workspace status into token claims', async () => {
|
|
61
|
+
const client = createClient();
|
|
62
|
+
const userId = crypto.randomUUID();
|
|
63
|
+
const active = await createWorkspaceForUser({
|
|
64
|
+
name: 'Active Workspace',
|
|
65
|
+
slug: `active-${crypto.randomUUID().slice(0, 8)}`,
|
|
66
|
+
userId,
|
|
67
|
+
});
|
|
68
|
+
const suspended = await createWorkspaceForUser({
|
|
69
|
+
name: 'Suspended Workspace',
|
|
70
|
+
slug: `suspended-${crypto.randomUUID().slice(0, 8)}`,
|
|
71
|
+
userId,
|
|
72
|
+
});
|
|
73
|
+
const deleted = await createWorkspaceForUser({
|
|
74
|
+
name: 'Deleted Workspace',
|
|
75
|
+
slug: `deleted-${crypto.randomUUID().slice(0, 8)}`,
|
|
76
|
+
userId,
|
|
77
|
+
});
|
|
78
|
+
await updateWorkspace({id: suspended.id, status: 'suspended'});
|
|
79
|
+
await updateWorkspace({id: deleted.id, status: 'deleted'});
|
|
80
|
+
|
|
81
|
+
expect(await client.listMembershipsForTokenClaims({userId})).toEqual({
|
|
82
|
+
memberships: [
|
|
83
|
+
{workspaceId: active.id, role: 'admin', workspaceStatus: 'active'},
|
|
84
|
+
{workspaceId: deleted.id, role: 'admin', workspaceStatus: 'deleted'},
|
|
85
|
+
{workspaceId: suspended.id, role: 'admin', workspaceStatus: 'suspended'},
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await updateWorkspace({id: suspended.id, status: 'active'});
|
|
90
|
+
|
|
91
|
+
expect(await client.listMembershipsForTokenClaims({userId})).toEqual({
|
|
92
|
+
memberships: [
|
|
93
|
+
{workspaceId: active.id, role: 'admin', workspaceStatus: 'active'},
|
|
94
|
+
{workspaceId: deleted.id, role: 'admin', workspaceStatus: 'deleted'},
|
|
95
|
+
{workspaceId: suspended.id, role: 'admin', workspaceStatus: 'active'},
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('preserves suspended-workspace access errors through the claim boundary', async () => {
|
|
101
|
+
const client = createClient();
|
|
102
|
+
const userId = crypto.randomUUID();
|
|
103
|
+
const workspace = await createWorkspaceForUser({
|
|
104
|
+
name: 'Suspended Access Workspace',
|
|
105
|
+
slug: `suspended-access-${crypto.randomUUID().slice(0, 8)}`,
|
|
106
|
+
userId,
|
|
107
|
+
});
|
|
108
|
+
await updateWorkspace({id: workspace.id, status: 'suspended'});
|
|
109
|
+
|
|
110
|
+
const claims = await client.listMembershipsForTokenClaims({userId});
|
|
111
|
+
const request = {};
|
|
112
|
+
setUserContext(
|
|
113
|
+
request,
|
|
114
|
+
buildUserContext({
|
|
115
|
+
userId,
|
|
116
|
+
email: `user-${userId}@example.com`,
|
|
117
|
+
memberships: claims.memberships,
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(() => requireWorkspaceAccess({request, workspaceId: workspace.id})).toThrow(
|
|
122
|
+
expect.objectContaining({code: 'workspace-suspended', status: 409}),
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
57
126
|
test('returns only the current workspace operating state', async () => {
|
|
58
127
|
const client = createClient();
|
|
59
128
|
const workspace = await createWorkspace({name: 'Operating State Workspace'});
|
|
@@ -26,10 +26,13 @@ export function createWorkspacesInterModulePresentation(): InterModulePresentati
|
|
|
26
26
|
> {
|
|
27
27
|
return defineInterModulePresentation(workspacesInterModuleContract, {
|
|
28
28
|
listMembershipsForTokenClaims: async ({userId}) => ({
|
|
29
|
-
memberships: (await listMembershipsByUser({userId})).map(
|
|
30
|
-
workspaceId,
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
memberships: (await listMembershipsByUser({userId})).map(
|
|
30
|
+
({workspaceId, workspaceStatus}) => ({
|
|
31
|
+
workspaceId,
|
|
32
|
+
role: 'admin' as const,
|
|
33
|
+
workspaceStatus,
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
33
36
|
}),
|
|
34
37
|
getWorkspaceCreator: async (input) => {
|
|
35
38
|
try {
|
|
@@ -5,7 +5,12 @@ import type {RouteGroup} from '@shipfox/node-fastify';
|
|
|
5
5
|
import {createAdminWorkspacesRoutes} from './admin-workspaces.js';
|
|
6
6
|
import {invitationsAcceptGroup, invitationsWorkspaceScopedRoutes} from './invitations/index.js';
|
|
7
7
|
import {memberRoutes} from './members/index.js';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
createWorkspaceRoute,
|
|
10
|
+
listUserWorkspacesRoute,
|
|
11
|
+
updateWorkspaceRoute,
|
|
12
|
+
workspaceSlugAvailabilityRoute,
|
|
13
|
+
} from './workspaces/index.js';
|
|
9
14
|
|
|
10
15
|
export const workspacesRoutes: RouteGroup[] = [
|
|
11
16
|
invitationsAcceptGroup,
|
|
@@ -14,6 +19,8 @@ export const workspacesRoutes: RouteGroup[] = [
|
|
|
14
19
|
routes: [
|
|
15
20
|
listUserWorkspacesRoute,
|
|
16
21
|
createWorkspaceRoute,
|
|
22
|
+
workspaceSlugAvailabilityRoute,
|
|
23
|
+
updateWorkspaceRoute,
|
|
17
24
|
{
|
|
18
25
|
prefix: '/:workspaceId/members',
|
|
19
26
|
routes: memberRoutes,
|
|
@@ -66,6 +66,21 @@ describe('POST /workspaces/:workspaceId/invitations', () => {
|
|
|
66
66
|
expect(await invitationOutboxEventsTo(inviteeEmail)).toHaveLength(1);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
test('returns workspace-suspended for a suspended membership claim', async () => {
|
|
70
|
+
const workspaceId = crypto.randomUUID();
|
|
71
|
+
const token = `claim:${crypto.randomUUID()}:suspended-invite-create@example.com:${workspaceId}:suspended`;
|
|
72
|
+
|
|
73
|
+
const res = await app.inject({
|
|
74
|
+
method: 'POST',
|
|
75
|
+
url: `/workspaces/${workspaceId}/invitations`,
|
|
76
|
+
headers: {authorization: `Bearer ${token}`},
|
|
77
|
+
payload: {email: uniqueEmail('suspended-invite')},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(res.statusCode).toBe(409);
|
|
81
|
+
expect(res.json().code).toBe('workspace-suspended');
|
|
82
|
+
});
|
|
83
|
+
|
|
69
84
|
test('transforms a whitespace/case-equivalent duplicate open invitation into 409', async () => {
|
|
70
85
|
const owner = await signupVerifyLogin(app, 'invite-create-duplicate-equivalent');
|
|
71
86
|
const workspaceId = await createWorkspace(app, owner.token);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
|
|
1
|
+
import {AUTH_USER, getUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';
|
|
2
2
|
import {createInvitationBodySchema, invitationDtoSchema} from '@shipfox/api-workspaces-dto';
|
|
3
3
|
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
4
4
|
import {z} from 'zod';
|
|
@@ -45,6 +45,7 @@ export const createInvitationRoute = defineRoute({
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const {workspaceId} = request.params;
|
|
48
|
+
requireWorkspaceAccess({request, workspaceId});
|
|
48
49
|
const {email} = request.body;
|
|
49
50
|
const invitation = await createWorkspaceInvitation({
|
|
50
51
|
workspaceId,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {FastifyInstance} from 'fastify';
|
|
2
|
+
import {updateWorkspace} from '#db/workspaces.js';
|
|
2
3
|
import {
|
|
3
4
|
createExpiredInvite,
|
|
4
5
|
createInvite,
|
|
@@ -46,6 +47,21 @@ describe('GET /workspaces/:workspaceId/invitations', () => {
|
|
|
46
47
|
]);
|
|
47
48
|
});
|
|
48
49
|
|
|
50
|
+
test('keeps an active claim usable when the workspace row is suspended', async () => {
|
|
51
|
+
const owner = await signupVerifyLogin(app, 'invite-list-snapshot');
|
|
52
|
+
const workspaceId = await createWorkspace(app, owner.token);
|
|
53
|
+
await updateWorkspace({id: workspaceId, status: 'suspended'});
|
|
54
|
+
|
|
55
|
+
const res = await app.inject({
|
|
56
|
+
method: 'GET',
|
|
57
|
+
url: `/workspaces/${workspaceId}/invitations`,
|
|
58
|
+
headers: {authorization: `Bearer ${owner.token}`},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(res.statusCode).toBe(200);
|
|
62
|
+
expect(res.json().invitations).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
49
65
|
test('excludes expired unaccepted invitations', async () => {
|
|
50
66
|
const owner = await signupVerifyLogin(app, 'invite-list-expired-owner');
|
|
51
67
|
const workspaceId = await createWorkspace(app, owner.token);
|
|
@@ -65,6 +81,20 @@ describe('GET /workspaces/:workspaceId/invitations', () => {
|
|
|
65
81
|
expect(res.json().invitations).toEqual([]);
|
|
66
82
|
});
|
|
67
83
|
|
|
84
|
+
test('returns workspace-suspended for a suspended membership claim', async () => {
|
|
85
|
+
const workspaceId = crypto.randomUUID();
|
|
86
|
+
const token = `claim:${crypto.randomUUID()}:suspended-invite-list@example.com:${workspaceId}:suspended`;
|
|
87
|
+
|
|
88
|
+
const res = await app.inject({
|
|
89
|
+
method: 'GET',
|
|
90
|
+
url: `/workspaces/${workspaceId}/invitations`,
|
|
91
|
+
headers: {authorization: `Bearer ${token}`},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(res.statusCode).toBe(409);
|
|
95
|
+
expect(res.json().code).toBe('workspace-suspended');
|
|
96
|
+
});
|
|
97
|
+
|
|
68
98
|
test('transforms missing membership into 403', async () => {
|
|
69
99
|
const outsider = await signupVerifyLogin(app, 'invite-list-outsider');
|
|
70
100
|
const workspaceId = crypto.randomUUID();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
|
|
1
|
+
import {AUTH_USER, getUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';
|
|
2
2
|
import {listInvitationsResponseSchema} from '@shipfox/api-workspaces-dto';
|
|
3
3
|
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
4
4
|
import {z} from 'zod';
|
|
@@ -40,6 +40,7 @@ export const listInvitationsRoute = defineRoute({
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const {workspaceId} = request.params;
|
|
43
|
+
requireWorkspaceAccess({request, workspaceId});
|
|
43
44
|
const invitations = await listWorkspaceInvitations({
|
|
44
45
|
workspaceId,
|
|
45
46
|
requesterUserId: client.userId,
|