Skip to content

Commit 2bba034

Browse files
committed
fix(app): correct knowledge access and chat state
1 parent 67e3f6d commit 2bba034

23 files changed

Lines changed: 905 additions & 153 deletions

File tree

apps/docs/content/docs/platform/enterprise/custom-blocks.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ Open a block from **Settings → Organization → Custom blocks** to edit or del
131131
},
132132
{
133133
question: "Can consumers see the workflow behind a block?",
134-
answer: "A custom block only exposes the inputs it needs and the outputs you chose to share, and consumers don't need any access to the source workflow. Its steps and intermediate values stay hidden unless you enable Trace runs in consumer logs, which surfaces them in the consumer's run trace."
134+
answer: "A custom block only exposes the inputs it needs and the outputs its publisher chose to share, and consumers don't need any access to the source workflow. Its steps and intermediate values stay hidden unless the block's publisher enables Trace runs in consumer logs, which surfaces them in the consumer's run trace."
135135
},
136136
{
137137
question: "Can I change which workflow a block points to?",

apps/docs/content/docs/platform/self-hosting/redis.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ Sim uses Redis as a message bus and shared cache. Both deployments ship it by de
2424
With more than one app or realtime replica and no `REDIS_URL`, users on different pods stop seeing each other's edits and live status updates. Beyond one startup log line noting single-pod mode, nothing is logged — the app looks healthy and quietly loses events. Treat Redis as mandatory the moment `replicaCount` exceeds 1.
2525
</Callout>
2626

27-
Everything Sim keeps in Redis is cache, coordination state, or an in-flight event — never committed data, which lives in PostgreSQL and object storage. Persistence is therefore not required. Losing or restarting the instance is not free, though: cancellation markers and the cross-pod half of execution streaming live here, so active runs stop streaming and a cancellation issued across the gap may not land. Completed work is unaffected.
27+
Committed database records and files live in PostgreSQL and object storage, so Redis persistence is not required to retain them. Redis also stores completed idempotency claims and results when it is the selected idempotency backend. Losing those keys can allow a retried webhook to repeat an already completed side effect.
28+
29+
Losing Redis state also drops cancellation markers and interrupts cross-pod execution streaming, so active runs can stop streaming and a cancellation issued across the gap may not land.
2830

2931
## Configuration
3032

@@ -63,7 +65,7 @@ app:
6365

6466
If the URL lives in a secret store instead — a pre-created Secret or one synced by External Secrets — it also wins, and there is nothing extra to configure. The bundled URL is delivered as a ConfigMap listed before the app Secret in `envFrom`, and Kubernetes lets the last source win for duplicate keys, so your value overrides it without the chart ever reading it.
6567

66-
The bundled Redis is deliberately non-persistent (`--save ""`, `--appendonly no`) with a 512 MB cap: Sim stores coordination state and short-lived keys in it, so a restart costs in-flight live updates rather than committed data.
68+
The bundled Redis is non-persistent (`--save ""`, `--appendonly no`) with a 512 MB cap. A restart loses its coordination state and idempotency keys, with the retry and live-update consequences described above.
6769

6870
If `networkPolicy.enabled=true`, egress to the bundled Redis is allowed automatically. An **external** Redis needs its own rule under `networkPolicy.egress` — the chart cannot know your host and port at render time.
6971

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({
8+
getSession: vi.fn(),
9+
resolveContext: vi.fn(),
10+
resolvePermission: vi.fn(),
11+
getConnector: vi.fn(),
12+
createEnrollmentLink: vi.fn(),
13+
requireAvailable: vi.fn(),
14+
}))
15+
16+
vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
17+
vi.mock('@sim/platform-authz/workspace', () => ({
18+
resolveEffectiveWorkspacePermission: mocks.resolvePermission,
19+
permissionSatisfies: (actual: string | null) => actual !== null,
20+
}))
21+
vi.mock('@/lib/knowledge/application/contexts', () => ({
22+
resolveActiveKnowledgeConnectorContext: mocks.resolveContext,
23+
}))
24+
vi.mock('@/lib/knowledge/application/connectors', () => ({
25+
requireConnectorWorkspaceId: (context: { workspaceId: string }) => context.workspaceId,
26+
requireSuccessfulOutcome: vi.fn(),
27+
resolveConnectorCredentialAccessToken: vi.fn(),
28+
}))
29+
vi.mock('@/lib/knowledge/orchestration/connectors', () => ({
30+
getKnowledgeConnector: mocks.getConnector,
31+
}))
32+
vi.mock('@/lib/knowledge/orchestration/connector-access', () => ({
33+
performUpdateKnowledgeConnectorAccess: vi.fn(),
34+
resolveKnowledgeConnectorMembersBinding: vi.fn(),
35+
}))
36+
vi.mock('@/lib/knowledge/connectors/member-provisioning', () => ({
37+
createViewerConnectorEnrollmentLink: mocks.createEnrollmentLink,
38+
}))
39+
vi.mock('@/lib/knowledge/access/availability', () => ({
40+
requireKnowledgeMemberAccessAvailable: mocks.requireAvailable,
41+
}))
42+
vi.mock('@/lib/credential-groups/enrollments', () => ({
43+
CredentialGroupEnrollmentError: class extends Error {
44+
constructor(
45+
message: string,
46+
readonly status: 400 | 404 | 409 | 502
47+
) {
48+
super(message)
49+
}
50+
},
51+
}))
52+
53+
import { OrchestrationError } from '@/lib/core/orchestration/types'
54+
import { CredentialGroupEnrollmentError } from '@/lib/credential-groups/enrollments'
55+
import { POST } from '@/app/api/knowledge/[id]/connectors/[connectorId]/enroll/route'
56+
57+
const context = { params: Promise.resolve({ id: 'kb-1', connectorId: 'connector-1' }) }
58+
const request = () => createMockRequest('POST')
59+
60+
describe('knowledge connector member enrollment', () => {
61+
beforeEach(() => {
62+
vi.clearAllMocks()
63+
mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } })
64+
mocks.resolvePermission.mockResolvedValue('read')
65+
mocks.resolveContext.mockResolvedValue({
66+
workspaceId: 'ws-1',
67+
workspaceOrganizationId: null,
68+
allowPersonalApiKeys: true,
69+
knowledgeBaseId: 'kb-1',
70+
connectorId: 'connector-1',
71+
})
72+
mocks.getConnector.mockResolvedValue({ accessMode: 'members', credentialGroupId: 'group-1' })
73+
mocks.createEnrollmentLink.mockResolvedValue('https://example.com/enroll')
74+
})
75+
76+
it.each([
77+
{ status: 404 as const, message: 'Credential group not found' },
78+
{ status: 409 as const, message: 'Credential group is disabled' },
79+
{
80+
status: 409 as const,
81+
message: 'Add an account type or OAuth MCP server before inviting people',
82+
},
83+
])('returns $status for $message', async ({ status, message }) => {
84+
mocks.createEnrollmentLink.mockRejectedValue(
85+
new CredentialGroupEnrollmentError(message, status)
86+
)
87+
88+
const response = await POST(request(), context)
89+
90+
expect(response.status).toBe(status)
91+
expect(await response.json()).toMatchObject({ error: message })
92+
})
93+
94+
it('preserves the revoked-enrollment refusal', async () => {
95+
mocks.createEnrollmentLink.mockRejectedValue(
96+
new OrchestrationError('forbidden', 'A workspace admin removed your access to this connector')
97+
)
98+
99+
const response = await POST(request(), context)
100+
101+
expect(response.status).toBe(403)
102+
expect(await response.json()).toMatchObject({
103+
error: 'A workspace admin removed your access to this connector',
104+
})
105+
})
106+
107+
it('does not disclose an unexpected enrollment failure', async () => {
108+
mocks.createEnrollmentLink.mockRejectedValue(new Error('Database connection failed'))
109+
110+
const response = await POST(request(), context)
111+
112+
expect(response.status).toBe(500)
113+
expect(await response.json()).toMatchObject({ error: 'Internal server error' })
114+
})
115+
116+
it('issues the current member an enrollment link', async () => {
117+
const response = await POST(request(), context)
118+
119+
expect(response.status).toBe(200)
120+
expect(await response.json()).toEqual({
121+
success: true,
122+
data: { url: 'https://example.com/enroll' },
123+
})
124+
expect(mocks.createEnrollmentLink).toHaveBeenCalledWith({
125+
userId: 'user-1',
126+
workspaceId: 'ws-1',
127+
credentialGroupId: 'group-1',
128+
})
129+
})
130+
})
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({ getSession: vi.fn(), listConnectors: vi.fn() }))
8+
9+
vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
10+
vi.mock('@/lib/knowledge/application/connectors', async () => {
11+
const { knowledgeOperations } = await import('@/lib/knowledge/application/operations')
12+
return {
13+
listWorkspaceMemberConnectors: {
14+
operation: knowledgeOperations.listWorkspaceMemberConnectors,
15+
execute: mocks.listConnectors,
16+
},
17+
}
18+
})
19+
20+
import { NoWorkspaceAccessError } from '@/lib/core/application'
21+
import { GET } from '@/app/api/knowledge/member-connectors/route'
22+
23+
const request = () =>
24+
createMockRequest(
25+
'GET',
26+
undefined,
27+
{},
28+
'http://localhost:3000/api/knowledge/member-connectors?workspaceId=ws-1'
29+
)
30+
31+
describe('workspace member connector listing', () => {
32+
beforeEach(() => {
33+
vi.clearAllMocks()
34+
mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } })
35+
mocks.listConnectors.mockResolvedValue({ connectors: [] })
36+
})
37+
38+
it('returns a workspace denial without concealing a nonexistent knowledge-base target', async () => {
39+
mocks.listConnectors.mockRejectedValue(new NoWorkspaceAccessError())
40+
41+
const response = await GET(request())
42+
43+
expect(response.status).toBe(403)
44+
expect(await response.json()).toMatchObject({ error: 'Insufficient workspace permissions' })
45+
})
46+
47+
it('preserves the connector-list response for an authorized member', async () => {
48+
const response = await GET(request())
49+
50+
expect(response.status).toBe(200)
51+
expect(await response.json()).toEqual({ success: true, data: [] })
52+
expect(mocks.listConnectors).toHaveBeenCalledWith(
53+
expect.objectContaining({
54+
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
55+
input: { workspaceId: 'ws-1' },
56+
})
57+
)
58+
})
59+
})

apps/sim/app/api/knowledge/member-connectors/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const GET = defineInternalJsonRoute({
1313
auth: internalSessionAuth,
1414
operation: knowledgeOperations.listWorkspaceMemberConnectors,
1515
rateLimit: internalRateLimits.none({ reason: 'One small read per visit to the Search tab' }),
16-
errorPolicy: internalKnowledgeErrorPolicies.connectors,
16+
errorPolicy: internalKnowledgeErrorPolicies.list,
1717
mapInput: ({ query }) => ({ workspaceId: query.workspaceId }),
1818
useCase: listWorkspaceMemberConnectors,
1919
present: ({ connectors }) => ({ success: true as const, data: connectors }),
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { NextResponse } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const mocks = vi.hoisted(() => ({
9+
authenticate: vi.fn(),
10+
resolveKnowledgeBase: vi.fn(),
11+
resolveAccess: vi.fn(),
12+
getDocument: vi.fn(),
13+
deleteDocument: vi.fn(),
14+
}))
15+
16+
vi.mock('@/app/api/v1/middleware', () => ({
17+
authenticateRequest: mocks.authenticate,
18+
v1ValidationErrorResponse: () => NextResponse.json({ error: 'Invalid request' }, { status: 400 }),
19+
}))
20+
vi.mock('@/app/api/v1/knowledge/utils', () => ({
21+
resolveKnowledgeBase: mocks.resolveKnowledgeBase,
22+
resolveV1KnowledgeAccessScope: mocks.resolveAccess,
23+
serializeDate: vi.fn(),
24+
handleError: () => NextResponse.json({ error: 'Unexpected error' }, { status: 500 }),
25+
}))
26+
vi.mock('@/lib/knowledge/documents/service', () => ({
27+
getKnowledgeDocument: mocks.getDocument,
28+
}))
29+
vi.mock('@/lib/knowledge/orchestration', () => ({
30+
performDeleteKnowledgeDocument: mocks.deleteDocument,
31+
}))
32+
33+
import { WORKSPACE_ACCESS_SCOPE } from '@/lib/knowledge/access/scope'
34+
import type { KnowledgeAccessScope } from '@/lib/knowledge/access/types'
35+
import { DELETE } from '@/app/api/v1/knowledge/[id]/documents/[documentId]/route'
36+
37+
const userAccess: KnowledgeAccessScope = {
38+
kind: 'user',
39+
userId: 'user-1',
40+
tokens: ['pub', 'ws', 's:credential-1'],
41+
}
42+
const context = { params: Promise.resolve({ id: 'kb-1', documentId: 'doc-1' }) }
43+
const request = () =>
44+
createMockRequest(
45+
'DELETE',
46+
undefined,
47+
{},
48+
'http://localhost:3000/api/v1/knowledge/kb-1/documents/doc-1?workspaceId=ws-1'
49+
)
50+
51+
describe('v1 knowledge document deletion', () => {
52+
beforeEach(() => {
53+
vi.clearAllMocks()
54+
mocks.authenticate.mockResolvedValue({
55+
userId: 'user-1',
56+
requestId: 'req-1',
57+
rateLimit: { keyType: 'personal' },
58+
})
59+
mocks.resolveKnowledgeBase.mockResolvedValue({
60+
kb: { id: 'kb-1', name: 'Knowledge base', workspaceId: 'ws-1' },
61+
})
62+
mocks.resolveAccess.mockResolvedValue(userAccess)
63+
mocks.getDocument.mockResolvedValue({ id: 'doc-1', filename: 'document.txt' })
64+
mocks.deleteDocument.mockResolvedValue({ success: true })
65+
})
66+
67+
it.each([
68+
{ keyType: 'personal', access: userAccess },
69+
{ keyType: 'workspace', access: WORKSPACE_ACCESS_SCOPE },
70+
])('carries $keyType access through lookup and deletion', async ({ keyType, access }) => {
71+
mocks.authenticate.mockResolvedValue({
72+
userId: 'user-1',
73+
requestId: 'req-1',
74+
rateLimit: { keyType },
75+
})
76+
mocks.resolveAccess.mockResolvedValue(access)
77+
78+
const response = await DELETE(request(), context)
79+
80+
expect(response.status).toBe(200)
81+
expect(mocks.resolveAccess).toHaveBeenCalledExactlyOnceWith('user-1', { keyType }, 'ws-1')
82+
expect(mocks.getDocument).toHaveBeenCalledWith('kb-1', 'doc-1', access)
83+
expect(mocks.deleteDocument).toHaveBeenCalledWith(
84+
expect.objectContaining({
85+
access,
86+
knowledgeBase: { id: 'kb-1', name: 'Knowledge base', workspaceId: 'ws-1' },
87+
})
88+
)
89+
expect(await response.json()).toEqual({
90+
success: true,
91+
data: { message: 'Document deleted successfully' },
92+
})
93+
})
94+
95+
it('returns not found when access is lost between lookup and deletion', async () => {
96+
mocks.deleteDocument.mockResolvedValue({
97+
success: false,
98+
errorCode: 'not_found',
99+
error: 'Document not found',
100+
})
101+
102+
const response = await DELETE(request(), context)
103+
104+
expect(response.status).toBe(404)
105+
expect(await response.json()).toEqual({ error: 'Document not found' })
106+
})
107+
108+
it('does not delete a document the initial lookup conceals', async () => {
109+
mocks.getDocument.mockResolvedValue(null)
110+
111+
expect((await DELETE(request(), context)).status).toBe(404)
112+
expect(mocks.deleteDocument).not.toHaveBeenCalled()
113+
})
114+
})

apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,17 +113,19 @@ export const DELETE = withRouteHandler(
113113
)
114114
if (result instanceof NextResponse) return result
115115

116-
const doc = await getKnowledgeDocument(
117-
knowledgeBaseId,
118-
documentId,
119-
await resolveV1KnowledgeAccessScope(userId, rateLimit, parsed.data.query.workspaceId)
116+
const access = await resolveV1KnowledgeAccessScope(
117+
userId,
118+
rateLimit,
119+
parsed.data.query.workspaceId
120120
)
121+
const doc = await getKnowledgeDocument(knowledgeBaseId, documentId, access)
121122

122123
if (!doc) {
123124
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
124125
}
125126

126127
const outcome = await performDeleteKnowledgeDocument({
128+
access,
127129
knowledgeBase: {
128130
id: knowledgeBaseId,
129131
name: result.kb.name,

0 commit comments

Comments
 (0)