Skip to content

Commit dec56c0

Browse files
committed
refactor(workspaces): apply /simplify review — invert stranded-provisioning to opt-in
Per multi-angle simplify/altitude review of the auto-provisioning fix: - archiveWorkspace's stranded-member handling is now opt-in (provisionFallbackForStrandedMembers, default off) instead of opt-out (force). A future caller of archiveWorkspace that doesn't know about this feature now gets the safe default (no side-effect workspace creation) rather than having to remember to pass force: true. Kept the logic inside archiveWorkspace's single transaction rather than splitting into two composable functions, since that would either reintroduce the TOCTOU race (separate transactions) or require threading an external tx through archiveWorkspace (bigger, riskier change for uncertain benefit). - provisionedWorkspaceUserIds is now returned from the transaction callback instead of mutated via an outer `let`. - Deduplicated CreateWorkspaceParams in app/api/workspaces/route.ts — now Omit<CreateWorkspaceRecordParams, 'executor'> instead of a hand-copied interface that could drift from the shared type. - Added a comment at the createWorkspaceRecord call site in archiveWorkspace noting it intentionally bypasses getWorkspaceCreationPolicy (system-provisioned safety net, not user self-service). - Renamed a colliding local test helper (createTx used for two different mock shapes in adjacent lib/workspaces test files). disableUserResources (ban flow) no longer needs a flag at all — the default now matches its existing behavior.
1 parent 17ed8f4 commit dec56c0

8 files changed

Lines changed: 81 additions & 72 deletions

File tree

apps/sim/app/api/workspaces/[id]/route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ describe('DELETE /api/workspaces/[id]', () => {
8383
expect(body).toEqual({ success: true })
8484
expect(mockArchiveWorkspace).toHaveBeenCalledWith('workspace-1', {
8585
requestId: 'workspace-workspace-1',
86+
provisionFallbackForStrandedMembers: true,
8687
actorId: 'user-admin',
8788
actorName: 'Admin',
8889
actorEmail: 'admin@example.com',

apps/sim/app/api/workspaces/[id]/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ export const DELETE = withRouteHandler(
250250

251251
const archiveResult = await archiveWorkspace(workspaceId, {
252252
requestId: `workspace-${workspaceId}`,
253+
provisionFallbackForStrandedMembers: true,
253254
actorId: session.user.id,
254255
actorName: session.user.name,
255256
actorEmail: session.user.email,

apps/sim/app/api/workspaces/route.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { PlanCategory } from '@/lib/billing/plan-helpers'
1212
import { PlatformEvents } from '@/lib/core/telemetry'
1313
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1414
import { captureServerEvent } from '@/lib/posthog/server'
15-
import { createWorkspaceRecord } from '@/lib/workspaces/create'
15+
import { type CreateWorkspaceRecordParams, createWorkspaceRecord } from '@/lib/workspaces/create'
1616
import {
1717
CONTACT_OWNER_TO_UPGRADE_REASON,
1818
evaluateWorkspaceInvitePolicy,
@@ -261,15 +261,7 @@ async function createDefaultWorkspace(
261261
})
262262
}
263263

264-
interface CreateWorkspaceParams {
265-
userId: string
266-
name: string
267-
skipDefaultWorkflow?: boolean
268-
explicitColor?: string
269-
organizationId: string | null
270-
workspaceMode: WorkspaceMode
271-
billedAccountUserId: string
272-
}
264+
type CreateWorkspaceParams = Omit<CreateWorkspaceRecordParams, 'executor'>
273265

274266
async function createWorkspace({
275267
userId,

apps/sim/lib/workflows/lifecycle.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ describe('disableUserResources', () => {
147147
vi.clearAllMocks()
148148
})
149149

150-
it('force-archives every owned workspace so banning is never blocked by other members', async () => {
150+
it('archives every owned workspace without opting into fallback provisioning, so banning is never blocked by other members', async () => {
151151
mockSelect.mockReturnValue(createSelectChain([{ id: 'workspace-1' }, { id: 'workspace-2' }]))
152152
mockDelete.mockReturnValue({ where: vi.fn().mockResolvedValue([]) })
153153
mockArchiveWorkspace.mockResolvedValue({ archived: true, workspaceName: 'Workspace' })
@@ -157,11 +157,11 @@ describe('disableUserResources', () => {
157157
expect(mockArchiveWorkspace).toHaveBeenCalledTimes(2)
158158
expect(mockArchiveWorkspace).toHaveBeenCalledWith(
159159
'workspace-1',
160-
expect.objectContaining({ force: true })
160+
expect.not.objectContaining({ provisionFallbackForStrandedMembers: true })
161161
)
162162
expect(mockArchiveWorkspace).toHaveBeenCalledWith(
163163
'workspace-2',
164-
expect.objectContaining({ force: true })
164+
expect.not.objectContaining({ provisionFallbackForStrandedMembers: true })
165165
)
166166
expect(mockDelete).toHaveBeenCalled()
167167
})

apps/sim/lib/workflows/lifecycle.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -360,9 +360,7 @@ export async function disableUserResources(userId: string): Promise<void> {
360360
.where(and(eq(workspace.ownerId, userId), isNull(workspace.archivedAt)))
361361

362362
await Promise.all([
363-
// force: true — a banned user's owned workspaces must be fully disabled regardless of
364-
// whether other members would be left with zero workspaces.
365-
...ownedWorkspaces.map((w) => archiveWorkspace(w.id, { requestId, force: true })),
363+
...ownedWorkspaces.map((w) => archiveWorkspace(w.id, { requestId })),
366364
db.delete(apiKey).where(eq(apiKey.userId, userId)),
367365
])
368366

apps/sim/lib/workspaces/create.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ vi.mock('@/lib/workspaces/colors', () => ({
2929

3030
import { createWorkspaceRecord } from './create'
3131

32-
function createTx() {
32+
function createInsertOnlyTx() {
3333
return {
3434
insert: vi.fn().mockImplementation(() => ({
3535
values: vi.fn().mockResolvedValue([]),
@@ -43,7 +43,7 @@ describe('createWorkspaceRecord', () => {
4343
})
4444

4545
it('opens its own transaction when no executor is provided', async () => {
46-
const tx = createTx()
46+
const tx = createInsertOnlyTx()
4747
mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise<void>) =>
4848
callback(tx)
4949
)
@@ -66,7 +66,7 @@ describe('createWorkspaceRecord', () => {
6666
})
6767

6868
it('runs directly against a provided executor instead of opening a nested transaction', async () => {
69-
const tx = createTx()
69+
const tx = createInsertOnlyTx()
7070

7171
await createWorkspaceRecord({
7272
userId: 'user-1',
@@ -82,7 +82,7 @@ describe('createWorkspaceRecord', () => {
8282
})
8383

8484
it('skips the default workflow insert when skipDefaultWorkflow is set', async () => {
85-
const tx = createTx()
85+
const tx = createInsertOnlyTx()
8686

8787
await createWorkspaceRecord({
8888
userId: 'user-1',
@@ -100,7 +100,7 @@ describe('createWorkspaceRecord', () => {
100100
})
101101

102102
it('adds a second admin permission row for the billed account when it differs from the owner in org mode', async () => {
103-
const tx = createTx()
103+
const tx = createInsertOnlyTx()
104104

105105
await createWorkspaceRecord({
106106
userId: 'user-1',

apps/sim/lib/workspaces/lifecycle.test.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,21 @@ describe('workspace lifecycle', () => {
105105
callback(tx)
106106
)
107107

108-
const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' })
108+
const result = await archiveWorkspace('workspace-1', {
109+
requestId: 'req-1',
110+
provisionFallbackForStrandedMembers: true,
111+
})
109112

110113
expect(result).toEqual({
111114
archived: true,
112115
workspaceName: 'Workspace 1',
113116
})
114117
expect(mockArchiveWorkflowsForWorkspace).toHaveBeenCalledWith('workspace-1', {
115118
requestId: 'req-1',
119+
provisionFallbackForStrandedMembers: true,
116120
})
117121
expect(tx.update).toHaveBeenCalledTimes(8)
118122
expect(tx.delete).toHaveBeenCalledTimes(1)
119-
expect(mockListAccessibleWorkspaceRowsForUser).not.toHaveBeenCalled()
120123
expect(mockCreateWorkspaceRecord).not.toHaveBeenCalled()
121124
expect(mockTransaction).toHaveBeenCalledWith(expect.any(Function), {
122125
isolationLevel: 'serializable',
@@ -142,6 +145,7 @@ describe('workspace lifecycle', () => {
142145

143146
const result = await archiveWorkspace('workspace-1', {
144147
requestId: 'req-1',
148+
provisionFallbackForStrandedMembers: true,
145149
actorId: 'admin-1',
146150
actorName: 'Admin',
147151
actorEmail: 'admin@example.com',
@@ -172,7 +176,6 @@ describe('workspace lifecycle', () => {
172176
}),
173177
})
174178
)
175-
// Deletion is never blocked — the workspace is still archived alongside the fallback creation.
176179
expect(tx.update).toHaveBeenCalledTimes(8)
177180
expect(tx.delete).toHaveBeenCalledTimes(1)
178181
})
@@ -194,7 +197,10 @@ describe('workspace lifecycle', () => {
194197
callback(tx)
195198
)
196199

197-
await archiveWorkspace('workspace-1', { requestId: 'req-1' })
200+
await archiveWorkspace('workspace-1', {
201+
requestId: 'req-1',
202+
provisionFallbackForStrandedMembers: true,
203+
})
198204

199205
expect(mockCreateWorkspaceRecord).toHaveBeenCalled()
200206
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
@@ -219,7 +225,10 @@ describe('workspace lifecycle', () => {
219225
callback(tx)
220226
)
221227

222-
const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' })
228+
const result = await archiveWorkspace('workspace-1', {
229+
requestId: 'req-1',
230+
provisionFallbackForStrandedMembers: true,
231+
})
223232

224233
expect(result).toEqual({
225234
archived: true,
@@ -240,8 +249,6 @@ describe('workspace lifecycle', () => {
240249
archivedAt: null,
241250
})
242251
mockArchiveWorkflowsForWorkspace.mockResolvedValue(0)
243-
// The org admin's only *explicit* permission row is on workspace-1, but they still have
244-
// access to workspace-2 purely through their organization admin role.
245252
mockListAccessibleWorkspaceRowsForUser.mockResolvedValue([
246253
accessibleWorkspaceRow('workspace-1'),
247254
accessibleWorkspaceRow('workspace-2'),
@@ -252,7 +259,10 @@ describe('workspace lifecycle', () => {
252259
callback(tx)
253260
)
254261

255-
const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' })
262+
const result = await archiveWorkspace('workspace-1', {
263+
requestId: 'req-1',
264+
provisionFallbackForStrandedMembers: true,
265+
})
256266

257267
expect(result).toEqual({
258268
archived: true,
@@ -280,18 +290,20 @@ describe('workspace lifecycle', () => {
280290
callback(tx)
281291
)
282292

283-
const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' })
293+
const result = await archiveWorkspace('workspace-1', {
294+
requestId: 'req-1',
295+
provisionFallbackForStrandedMembers: true,
296+
})
284297

285298
expect(result).toEqual({
286299
archived: true,
287300
workspaceName: 'Workspace 1',
288301
})
289302
expect(mockCreateWorkspaceRecord).not.toHaveBeenCalled()
290-
// No knowledge bases found, so the two KB-dependent updates (document, knowledgeConnector) are skipped.
291303
expect(tx.update).toHaveBeenCalledTimes(8)
292304
})
293305

294-
it('skips the stranded-member check and provisioning entirely when force is set (ban flow)', async () => {
306+
it('never checks or provisions when provisionFallbackForStrandedMembers is not set (ban flow default)', async () => {
295307
mockGetWorkspaceWithOwner.mockResolvedValue({
296308
id: 'workspace-1',
297309
name: 'Workspace 1',
@@ -305,7 +317,7 @@ describe('workspace lifecycle', () => {
305317
callback(tx)
306318
)
307319

308-
const result = await archiveWorkspace('workspace-1', { requestId: 'req-1', force: true })
320+
const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' })
309321

310322
expect(result).toEqual({
311323
archived: true,

apps/sim/lib/workspaces/lifecycle.ts

Lines changed: 44 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@ const FALLBACK_WORKSPACE_NAME = 'My Workspace'
3434
interface ArchiveWorkspaceOptions {
3535
requestId: string
3636
/**
37-
* Skips auto-provisioning replacement workspaces for members who'd otherwise be stranded.
38-
* Only for account-disable flows: a banned user's owned workspaces must be fully disabled, and
39-
* the banned user specifically should not be handed a fresh workspace as a side effect.
37+
* Opts into auto-provisioning a replacement workspace for any member who'd otherwise be left
38+
* with zero active workspaces. Off by default so archival stays a pure "delete this workspace"
39+
* primitive for callers that don't ask for it (e.g. the account-disable flow, where a banned
40+
* user's workspaces must be fully archived without handing the banned user a fresh one).
41+
* Only the interactive DELETE route should set this.
4042
*/
41-
force?: boolean
43+
provisionFallbackForStrandedMembers?: boolean
4244
/** Attributed as the actor on the audit log entry for any auto-provisioned replacement workspace. */
4345
actorId?: string
4446
actorName?: string | null
@@ -112,43 +114,44 @@ export async function archiveWorkspace(
112114

113115
// serializable: without it, two concurrent deletions sharing a sole member could each read a
114116
// pre-deletion workspace count and both skip provisioning a replacement. Postgres detects this
115-
// write skew under serializable isolation and aborts one transaction. Skipped when force is
116-
// set, since that path never runs the stranded-member check at all.
117-
const transactionConfig = options.force
118-
? undefined
119-
: ({ isolationLevel: 'serializable' } as const)
120-
121-
let provisionedWorkspaceUserIds: string[] = []
122-
123-
await db.transaction(async (tx) => {
124-
if (!options.force) {
125-
const strandedUserIds = await findMembersStrandedByArchival(tx, workspaceId)
126-
for (const userId of strandedUserIds) {
127-
const fallbackWorkspace = await createWorkspaceRecord({
128-
userId,
129-
name: FALLBACK_WORKSPACE_NAME,
130-
organizationId: null,
131-
workspaceMode: WORKSPACE_MODE.PERSONAL,
132-
billedAccountUserId: userId,
133-
executor: tx,
134-
})
117+
// write skew under serializable isolation and aborts one transaction. Only needed when the
118+
// stranded-member check actually runs.
119+
const transactionConfig = options.provisionFallbackForStrandedMembers
120+
? ({ isolationLevel: 'serializable' } as const)
121+
: undefined
122+
123+
const provisionedWorkspaceUserIds = await db.transaction(async (tx) => {
124+
if (!options.provisionFallbackForStrandedMembers) {
125+
return []
126+
}
127+
128+
const strandedUserIds = await findMembersStrandedByArchival(tx, workspaceId)
129+
for (const userId of strandedUserIds) {
130+
// Intentionally bypasses getWorkspaceCreationPolicy: this is a system-provisioned safety
131+
// net (never blocked by "who can create a workspace" rules), not user self-service.
132+
const fallbackWorkspace = await createWorkspaceRecord({
133+
userId,
134+
name: FALLBACK_WORKSPACE_NAME,
135+
organizationId: null,
136+
workspaceMode: WORKSPACE_MODE.PERSONAL,
137+
billedAccountUserId: userId,
138+
executor: tx,
139+
})
135140

136-
if (options.actorId) {
137-
recordAudit({
138-
workspaceId: fallbackWorkspace.id,
139-
actorId: options.actorId,
140-
actorName: options.actorName,
141-
actorEmail: options.actorEmail,
142-
action: AuditAction.WORKSPACE_CREATED,
143-
resourceType: AuditResourceType.WORKSPACE,
144-
resourceId: fallbackWorkspace.id,
145-
resourceName: fallbackWorkspace.name,
146-
description: `Auto-created replacement workspace "${fallbackWorkspace.name}" for a member left with no workspace after deleting "${workspaceRecord.name}"`,
147-
metadata: { deletedWorkspaceId: workspaceId, recipientUserId: userId },
148-
})
149-
}
141+
if (options.actorId) {
142+
recordAudit({
143+
workspaceId: fallbackWorkspace.id,
144+
actorId: options.actorId,
145+
actorName: options.actorName,
146+
actorEmail: options.actorEmail,
147+
action: AuditAction.WORKSPACE_CREATED,
148+
resourceType: AuditResourceType.WORKSPACE,
149+
resourceId: fallbackWorkspace.id,
150+
resourceName: fallbackWorkspace.name,
151+
description: `Auto-created replacement workspace "${fallbackWorkspace.name}" for a member left with no workspace after deleting "${workspaceRecord.name}"`,
152+
metadata: { deletedWorkspaceId: workspaceId, recipientUserId: userId },
153+
})
150154
}
151-
provisionedWorkspaceUserIds = strandedUserIds
152155
}
153156

154157
await tx
@@ -272,6 +275,8 @@ export async function archiveWorkspace(
272275
updatedAt: now,
273276
})
274277
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
278+
279+
return strandedUserIds
275280
}, transactionConfig)
276281

277282
await archiveWorkflowsForWorkspace(workspaceId, options)

0 commit comments

Comments
 (0)