@shipfox/api-workspaces 11.0.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 +34 -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 +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/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 +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.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
|
@@ -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,
|
|
@@ -21,6 +21,7 @@ describe('Workspaces inter-module presentation', () => {
|
|
|
21
21
|
const creatorUserId = crypto.randomUUID();
|
|
22
22
|
const workspace = await createWorkspaceForUser({
|
|
23
23
|
name: 'Creator Workspace',
|
|
24
|
+
slug: `creator-${crypto.randomUUID().slice(0, 8)}`,
|
|
24
25
|
userId: creatorUserId,
|
|
25
26
|
});
|
|
26
27
|
await createMembership({workspaceId: workspace.id, userId: crypto.randomUUID()});
|
|
@@ -35,6 +36,7 @@ describe('Workspaces inter-module presentation', () => {
|
|
|
35
36
|
const creatorUserId = crypto.randomUUID();
|
|
36
37
|
const workspace = await createWorkspaceForUser({
|
|
37
38
|
name: 'Outlasted Creator Workspace',
|
|
39
|
+
slug: `outlasted-${crypto.randomUUID().slice(0, 8)}`,
|
|
38
40
|
userId: creatorUserId,
|
|
39
41
|
});
|
|
40
42
|
const remainingUserId = crypto.randomUUID();
|
|
@@ -58,9 +60,21 @@ describe('Workspaces inter-module presentation', () => {
|
|
|
58
60
|
test('carries workspace status into token claims', async () => {
|
|
59
61
|
const client = createClient();
|
|
60
62
|
const userId = crypto.randomUUID();
|
|
61
|
-
const active = await createWorkspaceForUser({
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
});
|
|
64
78
|
await updateWorkspace({id: suspended.id, status: 'suspended'});
|
|
65
79
|
await updateWorkspace({id: deleted.id, status: 'deleted'});
|
|
66
80
|
|
|
@@ -86,7 +100,11 @@ describe('Workspaces inter-module presentation', () => {
|
|
|
86
100
|
test('preserves suspended-workspace access errors through the claim boundary', async () => {
|
|
87
101
|
const client = createClient();
|
|
88
102
|
const userId = crypto.randomUUID();
|
|
89
|
-
const workspace = await createWorkspaceForUser({
|
|
103
|
+
const workspace = await createWorkspaceForUser({
|
|
104
|
+
name: 'Suspended Access Workspace',
|
|
105
|
+
slug: `suspended-access-${crypto.randomUUID().slice(0, 8)}`,
|
|
106
|
+
userId,
|
|
107
|
+
});
|
|
90
108
|
await updateWorkspace({id: workspace.id, status: 'suspended'});
|
|
91
109
|
|
|
92
110
|
const claims = await client.listMembershipsForTokenClaims({userId});
|
|
@@ -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,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {ClientError, type FastifyReply, type FastifyRequest} from '@shipfox/node-fastify';
|
|
2
|
+
import {enforceRateLimit as enforceSharedRateLimit} from '@shipfox/node-rate-limit';
|
|
3
|
+
import {
|
|
4
|
+
checkWorkspacesRateLimit,
|
|
5
|
+
WORKSPACE_SLUG_AVAILABILITY_RATE_LIMIT,
|
|
6
|
+
} from '#core/rate-limit.js';
|
|
7
|
+
|
|
8
|
+
function routeName(request: FastifyRequest): string {
|
|
9
|
+
return request.routeOptions.url ?? request.url.split('?')[0] ?? 'unknown';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createWorkspaceSlugAvailabilityRateLimitPreHandler() {
|
|
13
|
+
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
|
14
|
+
await enforceSharedRateLimit({
|
|
15
|
+
request,
|
|
16
|
+
reply,
|
|
17
|
+
check: () =>
|
|
18
|
+
checkWorkspacesRateLimit({
|
|
19
|
+
action: 'slug-availability',
|
|
20
|
+
scope: 'ip',
|
|
21
|
+
identifier: request.ip,
|
|
22
|
+
...WORKSPACE_SLUG_AVAILABILITY_RATE_LIMIT,
|
|
23
|
+
}),
|
|
24
|
+
route: routeName(request),
|
|
25
|
+
unavailableCode: 'workspace-rate-limit-unavailable',
|
|
26
|
+
unavailableMessage: 'Workspace rate limiter unavailable',
|
|
27
|
+
setRetryAfter: (reply, retryAfterSeconds) => {
|
|
28
|
+
reply.header('Retry-After', String(retryAfterSeconds));
|
|
29
|
+
},
|
|
30
|
+
logWarn: (request, context) => {
|
|
31
|
+
request.log.warn(context, 'Workspace rate limit blocked request');
|
|
32
|
+
},
|
|
33
|
+
logError: (request, context) => {
|
|
34
|
+
request.log.error(context, 'Workspace rate limiter unavailable');
|
|
35
|
+
},
|
|
36
|
+
createClientError: (presentation, cause) =>
|
|
37
|
+
new ClientError(presentation.message, presentation.code, {
|
|
38
|
+
status: presentation.status,
|
|
39
|
+
...(presentation.details ? {details: presentation.details} : {}),
|
|
40
|
+
data: presentation.data,
|
|
41
|
+
cause,
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -47,13 +47,14 @@ describe('POST /workspaces', () => {
|
|
|
47
47
|
const res = await app.inject({
|
|
48
48
|
method: 'POST',
|
|
49
49
|
url: '/workspaces',
|
|
50
|
-
payload: {name: ' Test Workspace '},
|
|
50
|
+
payload: {name: ' Test Workspace ', slug: 'test-workspace'},
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
expect(res.statusCode).toBe(201);
|
|
54
54
|
const body = res.json();
|
|
55
55
|
expect(body.id).toBeDefined();
|
|
56
56
|
expect(body.name).toBe('Test Workspace');
|
|
57
|
+
expect(body.slug).toBe('test-workspace');
|
|
57
58
|
expect(body.status).toBe('active');
|
|
58
59
|
|
|
59
60
|
const membership = await findMembership({userId, workspaceId: body.id});
|
|
@@ -69,7 +70,7 @@ describe('POST /workspaces', () => {
|
|
|
69
70
|
const res = await app.inject({
|
|
70
71
|
method: 'POST',
|
|
71
72
|
url: '/workspaces',
|
|
72
|
-
payload: {name},
|
|
73
|
+
payload: {name, slug: 'test-workspace'},
|
|
73
74
|
});
|
|
74
75
|
|
|
75
76
|
expect(res.statusCode).toBe(400);
|
|
@@ -79,9 +80,38 @@ describe('POST /workspaces', () => {
|
|
|
79
80
|
const res = await app.inject({
|
|
80
81
|
method: 'POST',
|
|
81
82
|
url: '/workspaces',
|
|
82
|
-
payload: {},
|
|
83
|
+
payload: {slug: 'test-workspace'},
|
|
83
84
|
});
|
|
84
85
|
|
|
85
86
|
expect(res.statusCode).toBe(400);
|
|
86
87
|
});
|
|
88
|
+
|
|
89
|
+
test('accepts a workspace slugged settings', async () => {
|
|
90
|
+
const res = await app.inject({
|
|
91
|
+
method: 'POST',
|
|
92
|
+
url: '/workspaces',
|
|
93
|
+
payload: {name: 'Settings Workspace', slug: 'settings'},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(res.statusCode).toBe(201);
|
|
97
|
+
expect(res.json().slug).toBe('settings');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('returns slug-conflict for a duplicate slug', async () => {
|
|
101
|
+
const slug = `duplicate-${crypto.randomUUID().slice(0, 8)}`;
|
|
102
|
+
const first = await app.inject({
|
|
103
|
+
method: 'POST',
|
|
104
|
+
url: '/workspaces',
|
|
105
|
+
payload: {name: 'First Workspace', slug},
|
|
106
|
+
});
|
|
107
|
+
const second = await app.inject({
|
|
108
|
+
method: 'POST',
|
|
109
|
+
url: '/workspaces',
|
|
110
|
+
payload: {name: 'Second Workspace', slug},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(first.statusCode).toBe(201);
|
|
114
|
+
expect(second.statusCode).toBe(409);
|
|
115
|
+
expect(second.json().code).toBe('slug-conflict');
|
|
116
|
+
});
|
|
87
117
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
|
|
2
2
|
import {createWorkspaceBodySchema, workspaceResponseSchema} from '@shipfox/api-workspaces-dto';
|
|
3
3
|
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
4
|
+
import {WorkspaceSlugConflictError} from '#core/errors.js';
|
|
4
5
|
import {createWorkspaceForUser} from '#core/index.js';
|
|
5
6
|
import {toWorkspaceDto} from '#presentation/dto/index.js';
|
|
6
7
|
|
|
@@ -15,16 +16,23 @@ export const createWorkspaceRoute = defineRoute({
|
|
|
15
16
|
201: workspaceResponseSchema,
|
|
16
17
|
},
|
|
17
18
|
},
|
|
19
|
+
errorHandler: (error) => {
|
|
20
|
+
if (error instanceof WorkspaceSlugConflictError) {
|
|
21
|
+
throw new ClientError('Workspace slug is already taken', 'slug-conflict', {status: 409});
|
|
22
|
+
}
|
|
23
|
+
throw error;
|
|
24
|
+
},
|
|
18
25
|
handler: async (request, reply) => {
|
|
19
26
|
const client = getUserContext(request);
|
|
20
27
|
if (!client) {
|
|
21
28
|
throw new ClientError('Authentication required', 'unauthorized', {status: 401});
|
|
22
29
|
}
|
|
23
30
|
|
|
24
|
-
const {name} = request.body;
|
|
31
|
+
const {name, slug} = request.body;
|
|
25
32
|
|
|
26
33
|
const workspace = await createWorkspaceForUser({
|
|
27
34
|
name,
|
|
35
|
+
slug,
|
|
28
36
|
userId: client.userId,
|
|
29
37
|
userEmail: client.email,
|
|
30
38
|
userName: client.name,
|