Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions apps/sim/app/api/users/me/deletion/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
defineInternalJsonRoute: vi.fn(() => vi.fn()),
signOut: vi.fn(),
}))

vi.mock('@/lib/api/server/routes', () => ({
defineInternalJsonRoute: mocks.defineInternalJsonRoute,
internalOrchestrationErrorPolicy: { project: vi.fn(), unhandled: vi.fn() },
internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) },
internalSessionAuth: { authenticate: vi.fn() },
}))

vi.mock('@/lib/auth', () => ({
auth: { api: { signOut: mocks.signOut } },
getSession: vi.fn(),
}))

vi.mock('@/lib/users/application/delete-account', () => ({
deleteAccountUseCase: { execute: vi.fn() },
previewAccountDeletionUseCase: { execute: vi.fn() },
}))

vi.mock('@/lib/users/application/operations', () => ({
userAccountOperations: { delete: { id: 'user.delete' }, previewDeletion: { id: 'user.preview' } },
}))

import '@/app/api/users/me/deletion/route'

type RouteOptions = {
finalizeResponse?: (args: { request: Request }) => Promise<{ headers?: HeadersInit }>
}

/** Captured at import time; the route registers itself once when the module loads. */
const routeOptions = mocks.defineInternalJsonRoute.mock.calls.map((call) => call[0] as RouteOptions)

describe('POST /api/users/me/deletion', () => {
beforeEach(() => {
mocks.signOut.mockReset()
})

/**
* The session row is deleted with the account, but the signed cookie cache
* still authenticates the browser for its TTL; the response must clear it.
*/
it('clears the session cookies on the deletion response', async () => {
const options = routeOptions.find(
(candidate) => typeof candidate.finalizeResponse === 'function'
)
expect(options?.finalizeResponse).toBeDefined()

const cleared = new Headers([['set-cookie', 'better-auth.session_token=; Max-Age=0']])
mocks.signOut.mockResolvedValue({ headers: cleared, response: { success: true } })
const request = new Request('http://localhost/api/users/me/deletion', {
method: 'POST',
headers: { cookie: 'better-auth.session_token=abc' },
})

const finalization = await options!.finalizeResponse!({ request })

expect(mocks.signOut).toHaveBeenCalledWith({ headers: request.headers, returnHeaders: true })
expect(finalization.headers).toBe(cleared)
})

it('never fails a completed deletion because the cookies could not be cleared', async () => {
const options = routeOptions.find(
(candidate) => typeof candidate.finalizeResponse === 'function'
)
mocks.signOut.mockRejectedValue(new Error('sign-out unavailable'))

await expect(
options!.finalizeResponse!({
request: new Request('http://localhost/api/users/me/deletion', { method: 'POST' }),
})
).resolves.toEqual({})
})
})
19 changes: 19 additions & 0 deletions apps/sim/app/api/users/me/deletion/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { createLogger } from '@sim/logger'
import { deleteAccountContract, getAccountDeletionPlanContract } from '@/lib/api/contracts'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { auth } from '@/lib/auth'
import {
deleteAccountUseCase,
previewAccountDeletionUseCase,
} from '@/lib/users/application/delete-account'
import { userAccountOperations } from '@/lib/users/application/operations'

const logger = createLogger('AccountDeletionRoute')

export const dynamic = 'force-dynamic'

export const GET = defineInternalJsonRoute({
Expand Down Expand Up @@ -39,4 +43,19 @@ export const POST = defineInternalJsonRoute({
mapInput: ({ body }) => ({ confirmEmail: body.confirmEmail }),
useCase: deleteAccountUseCase,
present: () => ({ success: true as const }),
/**
* The session row is gone, but the signed cookie cache authenticates this
* browser for up to five more minutes. Clear the cookies on the deletion
* response itself so nothing the page does afterwards can carry them.
*/
finalizeResponse: async ({ request }) => {
try {
const { headers } = await auth.api.signOut({ headers: request.headers, returnHeaders: true })
return { headers }
} catch (error) {
/** The account is gone either way; the client's own sign-out and full reload still drop the cookie. */
logger.warn('Could not clear session cookies after account deletion', { error })
return {}
}
},
})
33 changes: 31 additions & 2 deletions apps/sim/app/api/workspaces/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,21 @@ vi.mock('@sim/audit', () => ({
vi.mock('@/lib/workspaces/policy', async () => {
class WorkspaceCreationCapabilityWithheldError extends Error {}
class WorkspaceCreationContextChangedError extends Error {}
class WorkspaceOwnerMissingError extends Error {}
return {
getWorkspaceCreationPolicy: mockGetWorkspaceCreationPolicy,
WorkspaceCreationCapabilityWithheldError,
WorkspaceCreationContextChangedError,
WorkspaceOwnerMissingError,
}
})

import { WorkspaceCreationCapabilityWithheldError } from '@/lib/workspaces/policy'
import { POST } from '@/app/api/workspaces/route'
import { listWorkspacesForViewer } from '@/lib/workspaces/list'
import {
WorkspaceCreationCapabilityWithheldError,
WorkspaceOwnerMissingError,
} from '@/lib/workspaces/policy'
import { GET, POST } from '@/app/api/workspaces/route'

function createRequest() {
return createMockRequest('POST', { name: 'New workspace' })
Expand Down Expand Up @@ -116,4 +122,27 @@ describe('POST /api/workspaces capability refusal', () => {
expect(body.error).toBe('Your organization subscription is inactive.')
expect(body.details).toBeUndefined()
})

/**
* After account deletion the browser's cached session cookie stays valid for
* a few minutes, and the next list load finds no workspaces and tries to
* create the default one for a user who no longer exists.
*/
it('answers a default-workspace insert for a deleted user with 401', async () => {
mockGetSession.mockResolvedValue({ user: { id: 'user-1', name: 'Gone' } })
vi.mocked(listWorkspacesForViewer).mockResolvedValue({
workspaces: [],
lastActiveWorkspaceId: null,
pinnedWorkspaceIds: [],
creationPolicy: { canCreate: true, organizationId: null, billedAccountUserId: 'user-1' },
} as never)
mockCreateWorkspace.mockRejectedValue(new WorkspaceOwnerMissingError('user-1'))

const response = await GET(
createMockRequest('GET', undefined, undefined, 'http://localhost/api/workspaces?scope=active')
)

expect(response.status).toBe(401)
await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' })
})
})
9 changes: 9 additions & 0 deletions apps/sim/app/api/workspaces/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
getWorkspaceCreationPolicy,
WorkspaceCreationCapabilityWithheldError,
WorkspaceCreationContextChangedError,
WorkspaceOwnerMissingError,
} from '@/lib/workspaces/policy'

const logger = createLogger('Workspaces')
Expand Down Expand Up @@ -87,6 +88,10 @@ export const GET = withRouteHandler(async (request: Request) => {
})
return NextResponse.json(refreshedPayload)
}
/** A cached session cookie outlived the account it belongs to. */
if (error instanceof WorkspaceOwnerMissingError) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
throw error
}

Expand Down Expand Up @@ -207,6 +212,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
{ status: 409 }
)
}
/** A cached session cookie outlived the account it belongs to. */
if (error instanceof WorkspaceOwnerMissingError) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
/**
* A lock timeout is contention, not a fault: creation serializes on the
* organization's mutation locks and now also on `permission_group:<org>`,
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/background/knowledge-connector-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,20 @@ describe('knowledge connector sync worker', () => {
).toBe('completed')
})

it('keeps a held deletion pass a completed task', () => {
const clean = {
docsAdded: 0,
docsUpdated: 0,
docsDeleted: 0,
docsUnchanged: 90,
docsSkipped: 0,
docsFailed: 0,
processingDispatch: { requested: 0, accepted: 0, failed: 0 },
}
expect(classifyConnectorSyncResult({ ...clean, listingIncomplete: false })).toBe('completed')
expect(classifyConnectorSyncResult({ ...clean, listingIncomplete: true })).toBe('partial')
})

it('classifies an isolated processing dispatch failure as partial', () => {
expect(
classifyConnectorSyncResult({
Expand Down
10 changes: 7 additions & 3 deletions apps/sim/blocks/blocks/mothership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const MothershipBlock: BlockConfig<MothershipResponse> = {
id: 'conversationId',
title: 'Conversation ID',
type: 'short-input',
placeholder: 'e.g., user-123, session-abc, customer-456',
placeholder: 'e.g., customer-456 (reuse the same value to continue a thread)',
},
{
id: 'attachmentFiles',
Expand Down Expand Up @@ -117,7 +117,8 @@ export const MothershipBlock: BlockConfig<MothershipResponse> = {
},
conversationId: {
type: 'string',
description: 'Chat ID to continue; generated when omitted',
description:
'Stable id of the thread to continue; the same value in this workspace continues the same conversation. Generated when omitted',
},
files: {
type: 'file',
Expand All @@ -131,7 +132,10 @@ export const MothershipBlock: BlockConfig<MothershipResponse> = {
outputs: {
content: { type: 'string', description: 'Generated response content' },
model: { type: 'string', description: 'Model used for generation' },
conversationId: { type: 'string', description: 'Chat ID used for this request' },
conversationId: {
type: 'string',
description: 'Conversation id for this thread; pass it to another Sim block to continue it',
},
tokens: { type: 'json', description: 'Token usage statistics' },
toolCalls: { type: 'json', description: 'Tool calls made during execution' },
cost: { type: 'json', description: 'Cost of the execution' },
Expand Down
26 changes: 1 addition & 25 deletions apps/sim/ee/workspace-forking/lib/remap/block-identity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createHash } from 'node:crypto'
import { uuidV5 } from '@/lib/core/utils/uuid-v5'

/**
* Fixed namespace UUID for fork block-identity derivation. Changing this value
Expand All @@ -8,30 +8,6 @@ import { createHash } from 'node:crypto'
*/
const FORK_BLOCK_NAMESPACE = '6f1c0e2a-9b3d-5e47-8a1c-2d4f6b8e0c13'

function uuidToBytes(uuid: string): Buffer {
return Buffer.from(uuid.replace(/-/g, ''), 'hex')
}

/**
* Deterministic UUIDv5 (SHA-1) of `name` within `namespace`. The same inputs
* always yield the same UUID, which is how fork block identity stays stable.
*
* SHA-1 is mandated by RFC 4122 for UUIDv5 and is used here only for deterministic id derivation,
* never for secrecy or integrity — not a security use of the algorithm. Swapping it would change
* every derived id, breaking webhook URLs and stored block-id references across existing forks
* (see {@link FORK_BLOCK_NAMESPACE}).
*/
function uuidV5(name: string, namespace: string): string {
const hash = createHash('sha1')
hash.update(uuidToBytes(namespace)) // lgtm[js/weak-cryptographic-algorithm]
hash.update(Buffer.from(name, 'utf8')) // lgtm[js/weak-cryptographic-algorithm]
const bytes = hash.digest().subarray(0, 16)
bytes[6] = (bytes[6] & 0x0f) | 0x50
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = bytes.toString('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}

/**
* Derive the target block id for a source block copied into a target workflow.
*
Expand Down
14 changes: 9 additions & 5 deletions apps/sim/executor/handlers/mothership/mothership-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import '@sim/testing/mocks/executor'

import { loggerMock, resetEnvMock, setEnv } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { resolveMothershipConversation } from '@/lib/mothership/conversation-id'
import { BlockType } from '@/executor/constants'
import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler'
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
Expand Down Expand Up @@ -792,7 +793,7 @@ describe('MothershipBlockHandler', () => {
messages: [{ role: 'user', content: 'Hello from workflow' }],
workspaceId: 'workspace-1',
userId: 'user-1',
chatId: 'chat-uuid',
chatId: resolveMothershipConversation('workspace-1', 'chat-uuid').chatId,
messageId: 'message-uuid',
requestId: 'request-uuid',
secretScope: 'all',
Expand Down Expand Up @@ -876,7 +877,7 @@ describe('MothershipBlockHandler', () => {
messages: [{ role: 'user', content: 'Continue this thread' }],
workspaceId: 'workspace-1',
userId: 'user-1',
chatId: 'existing-chat-id',
chatId: resolveMothershipConversation('workspace-1', 'existing-chat-id').chatId,
messageId: 'message-uuid',
requestId: 'request-uuid',
secretScope: 'all',
Expand All @@ -887,7 +888,7 @@ describe('MothershipBlockHandler', () => {
expect(mockGenerateId).toHaveBeenCalledTimes(2)
})

it('keeps a resolved conversation ID out of logs while forwarding it unchanged', async () => {
it('keeps a resolved conversation ID out of logs and off the wire', async () => {
const conversationId = 'chat-plaintext-secret-__var_API_KEY-__sim_secret_API_KEY'
mockGenerateId.mockReturnValueOnce('message-uuid').mockReturnValueOnce('request-uuid')
fetchMock.mockResolvedValue(
Expand All @@ -907,7 +908,8 @@ describe('MothershipBlockHandler', () => {

const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
const body = JSON.parse(String(options.body))
expect(body.chatId).toBe(conversationId)
expect(body.chatId).toBe(resolveMothershipConversation('workspace-1', conversationId).chatId)
expect(body.chatId).not.toContain('chat-plaintext-secret')

const logged = JSON.stringify(mockMothershipLogger.info.mock.calls)
expect(logged).not.toContain('chat-plaintext-secret')
Expand Down Expand Up @@ -936,7 +938,9 @@ describe('MothershipBlockHandler', () => {
const result = await handler.execute(context, block, inputs)

const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(JSON.parse(String(options.body)).chatId).toBe('x')
expect(JSON.parse(String(options.body)).chatId).toBe(
resolveMothershipConversation('workspace-1', 'x').chatId
)
expect(result).toMatchObject({ conversationId: 'x' })
expect(inputs.conversationId).toBe('x')
expect(context.resolvedSecretTraceRegistry?.getActiveMatches()).toEqual([])
Expand Down
Loading
Loading