Skip to content

Commit 1bd77e6

Browse files
committed
feat(tables): add read-only memory virtual table
1 parent 10bfb5d commit 1bd77e6

40 files changed

Lines changed: 21229 additions & 105 deletions

apps/sim/app/api/table/[tableId]/columns/route.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,14 @@ vi.mock('@/lib/table', () => ({
4343
updateColumnType: mockUpdateColumnType,
4444
}))
4545
vi.mock('@/app/api/table/utils', () => ({
46-
accessError: () => new Response('denied', { status: 403 }),
46+
accessError: (result: { status: number }) => new Response('denied', { status: result.status }),
4747
checkAccess: mockCheckAccess,
4848
normalizeColumn: (c: unknown) => c,
4949
rootErrorMessage: (e: unknown) => getErrorMessage(e),
5050
tableLockErrorResponse: () => null,
5151
}))
5252

53-
import { PATCH } from '@/app/api/table/[tableId]/columns/route'
53+
import { DELETE, PATCH, POST } from '@/app/api/table/[tableId]/columns/route'
5454

5555
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
5656

@@ -83,6 +83,48 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
8383
mockRenameColumn.mockResolvedValue({ schema: { columns: [] } })
8484
})
8585

86+
it('rejects synthetic Memory column writes with a read-only explanation', async () => {
87+
mockCheckAccess.mockResolvedValue({ ok: false, status: 423 })
88+
const tableId = `system_memory_${WORKSPACE_ID}`
89+
const response = await PATCH(
90+
new NextRequest(`http://localhost/api/table/${tableId}/columns`, {
91+
method: 'PATCH',
92+
body: JSON.stringify({
93+
workspaceId: WORKSPACE_ID,
94+
columnName: 'transcript',
95+
updates: { name: 'Messages' },
96+
}),
97+
headers: { 'content-type': 'application/json' },
98+
}),
99+
{ params: Promise.resolve({ tableId }) }
100+
)
101+
102+
expect(response.status).toBe(423)
103+
expect(mockCheckAccess).toHaveBeenCalledWith(tableId, 'user-1', 'write')
104+
expect(mockRenameColumn).not.toHaveBeenCalled()
105+
})
106+
107+
it.each([
108+
['POST', POST, { workspaceId: WORKSPACE_ID, column: { name: 'Extra', type: 'string' } }],
109+
['DELETE', DELETE, { workspaceId: WORKSPACE_ID, columnName: 'transcript' }],
110+
])('rejects synthetic Memory %s writes through shared access', async (_method, handler, body) => {
111+
mockCheckAccess.mockResolvedValue({ ok: false, status: 423 })
112+
const tableId = `system_memory_${WORKSPACE_ID}`
113+
const response = await handler(
114+
new NextRequest(`http://localhost/api/table/${tableId}/columns`, {
115+
method: _method,
116+
body: JSON.stringify(body),
117+
headers: { 'content-type': 'application/json' },
118+
}),
119+
{ params: Promise.resolve({ tableId }) }
120+
)
121+
122+
expect(response.status).toBe(423)
123+
expect(mockCheckAccess).toHaveBeenCalledWith(tableId, 'user-1', 'write')
124+
expect(mockAddTableColumn).not.toHaveBeenCalled()
125+
expect(mockDeleteColumn).not.toHaveBeenCalled()
126+
})
127+
86128
it('rejects a currency code on a non-currency column without renaming first', async () => {
87129
const response = await patch({ name: 'renamed', currencyCode: 'USD' })
88130

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { hybridAuthMockFns } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockCheckAccess, mockUpdateTableMetadata } = vi.hoisted(() => ({
9+
mockCheckAccess: vi.fn(),
10+
mockUpdateTableMetadata: vi.fn(),
11+
}))
12+
13+
vi.mock('@/lib/table', () => ({
14+
updateTableMetadata: mockUpdateTableMetadata,
15+
}))
16+
17+
vi.mock('@/app/api/table/utils', () => ({
18+
accessError: (result: { status: number }) => new Response('denied', { status: result.status }),
19+
checkAccess: mockCheckAccess,
20+
}))
21+
22+
import { PUT } from '@/app/api/table/[tableId]/metadata/route'
23+
24+
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
25+
26+
describe('PUT /api/table/[tableId]/metadata', () => {
27+
beforeEach(() => {
28+
vi.clearAllMocks()
29+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
30+
success: true,
31+
userId: 'user-1',
32+
authType: 'session',
33+
})
34+
})
35+
36+
it('rejects synthetic Memory metadata writes through shared access', async () => {
37+
mockCheckAccess.mockResolvedValue({ ok: false, status: 423 })
38+
const tableId = `system_memory_${WORKSPACE_ID}`
39+
const response = await PUT(
40+
new NextRequest(`http://localhost/api/table/${tableId}/metadata`, {
41+
method: 'PUT',
42+
headers: { 'content-type': 'application/json' },
43+
body: JSON.stringify({
44+
workspaceId: WORKSPACE_ID,
45+
metadata: { columnWidths: { transcript: 320 } },
46+
}),
47+
}),
48+
{ params: Promise.resolve({ tableId }) }
49+
)
50+
51+
expect(response.status).toBe(423)
52+
expect(mockCheckAccess).toHaveBeenCalledWith(tableId, 'user-1', 'write')
53+
expect(mockUpdateTableMetadata).not.toHaveBeenCalled()
54+
})
55+
})

apps/sim/app/api/table/[tableId]/route.test.ts

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {
1414
mockUpdateTableLocks,
1515
mockFindActiveFolder,
1616
mockGetLimits,
17+
mockGetUserEntityPermissions,
1718
} = vi.hoisted(() => ({
1819
mockCheckAccess: vi.fn(),
1920
mockDeleteTable: vi.fn(),
@@ -23,6 +24,7 @@ const {
2324
mockUpdateTableLocks: vi.fn(),
2425
mockFindActiveFolder: vi.fn(),
2526
mockGetLimits: vi.fn(),
27+
mockGetUserEntityPermissions: vi.fn(),
2628
}))
2729

2830
vi.mock('@/lib/table', () => ({
@@ -39,16 +41,16 @@ vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: vi.fn() })
3941
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))
4042
vi.mock('@/lib/workspaces/permissions/utils', () => ({
4143
getWorkspaceWithOwner: vi.fn(),
42-
getUserEntityPermissions: vi.fn(),
44+
getUserEntityPermissions: mockGetUserEntityPermissions,
4345
}))
4446
vi.mock('@/app/api/table/utils', () => ({
45-
accessError: () => new Response('denied', { status: 403 }),
47+
accessError: (result: { status: number }) => new Response('denied', { status: result.status }),
4648
checkAccess: mockCheckAccess,
4749
normalizeColumn: (column: unknown) => column,
4850
tableLockErrorResponse: () => null,
4951
}))
5052

51-
import { PATCH } from '@/app/api/table/[tableId]/route'
53+
import { DELETE, GET, PATCH } from '@/app/api/table/[tableId]/route'
5254

5355
const TABLE = {
5456
id: 'tbl_1',
@@ -74,6 +76,74 @@ function patchRequest(body: unknown): NextRequest {
7476

7577
const routeContext = { params: Promise.resolve({ tableId: 'tbl_1' }) }
7678

79+
describe('GET /api/table/[tableId] Memory table', () => {
80+
beforeEach(() => {
81+
vi.clearAllMocks()
82+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
83+
success: true,
84+
userId: 'user-1',
85+
authType: 'session',
86+
})
87+
mockGetUserEntityPermissions.mockResolvedValue('read')
88+
mockCheckAccess.mockResolvedValue({
89+
ok: true,
90+
table: {
91+
...TABLE,
92+
id: 'system_memory_workspace-1',
93+
name: 'Memory',
94+
isVirtual: true,
95+
rowCount: 1,
96+
maxRows: Number.MAX_SAFE_INTEGER,
97+
createdBy: 'user-1',
98+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
99+
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
100+
locks: {
101+
schemaLocked: true,
102+
insertLocked: true,
103+
updateLocked: true,
104+
deleteLocked: true,
105+
},
106+
},
107+
})
108+
mockGetLimits.mockResolvedValue({ maxRowsPerTable: 10_000 })
109+
})
110+
111+
it('returns the synthetic table to a workspace reader', async () => {
112+
const response = await GET(
113+
new NextRequest(
114+
'http://localhost:3000/api/table/system_memory_workspace-1?workspaceId=workspace-1'
115+
),
116+
{ params: Promise.resolve({ tableId: 'system_memory_workspace-1' }) }
117+
)
118+
const json = await response.json()
119+
120+
expect(response.status).toBe(200)
121+
expect(json.data.table).toMatchObject({
122+
id: 'system_memory_workspace-1',
123+
name: 'Memory',
124+
isVirtual: true,
125+
rowCount: 1,
126+
maxRows: 10_000,
127+
})
128+
expect(mockCheckAccess).toHaveBeenCalledWith('system_memory_workspace-1', 'user-1', 'read')
129+
expect(mockGetLimits).toHaveBeenCalledWith('workspace-1')
130+
})
131+
132+
it('does not expose the table to someone outside the workspace', async () => {
133+
mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
134+
135+
const response = await GET(
136+
new NextRequest(
137+
'http://localhost:3000/api/table/system_memory_workspace-1?workspaceId=workspace-1'
138+
),
139+
{ params: Promise.resolve({ tableId: 'system_memory_workspace-1' }) }
140+
)
141+
142+
expect(response.status).toBe(403)
143+
expect(mockGetLimits).not.toHaveBeenCalled()
144+
})
145+
})
146+
77147
describe('PATCH /api/table/[tableId] folder moves', () => {
78148
beforeEach(() => {
79149
vi.clearAllMocks()
@@ -149,4 +219,48 @@ describe('PATCH /api/table/[tableId] folder moves', () => {
149219
expect(mockMoveTableToFolder).not.toHaveBeenCalled()
150220
expect(mockRenameTable).not.toHaveBeenCalled()
151221
})
222+
223+
it('rejects synthetic Memory table writes with a read-only explanation', async () => {
224+
mockCheckAccess.mockResolvedValue({ ok: false, status: 423 })
225+
226+
const response = await PATCH(
227+
new NextRequest('http://localhost:3000/api/table/system_memory_workspace-1', {
228+
method: 'PATCH',
229+
headers: { 'content-type': 'application/json' },
230+
body: JSON.stringify({ workspaceId: 'workspace-1', name: 'Renamed' }),
231+
}),
232+
{ params: Promise.resolve({ tableId: 'system_memory_workspace-1' }) }
233+
)
234+
235+
expect(response.status).toBe(423)
236+
expect(mockCheckAccess).toHaveBeenCalledWith('system_memory_workspace-1', 'user-1', 'write')
237+
expect(mockRenameTable).not.toHaveBeenCalled()
238+
expect(mockUpdateTableLocks).not.toHaveBeenCalled()
239+
})
240+
})
241+
242+
describe('DELETE /api/table/[tableId] Memory table', () => {
243+
beforeEach(() => {
244+
vi.clearAllMocks()
245+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
246+
success: true,
247+
userId: 'user-1',
248+
authType: 'session',
249+
})
250+
})
251+
252+
it('rejects deletion through shared access', async () => {
253+
mockCheckAccess.mockResolvedValue({ ok: false, status: 423 })
254+
const tableId = 'system_memory_workspace-1'
255+
const response = await DELETE(
256+
new NextRequest(`http://localhost:3000/api/table/${tableId}?workspaceId=workspace-1`, {
257+
method: 'DELETE',
258+
}),
259+
{ params: Promise.resolve({ tableId }) }
260+
)
261+
262+
expect(response.status).toBe(423)
263+
expect(mockCheckAccess).toHaveBeenCalledWith(tableId, 'user-1', 'write')
264+
expect(mockDeleteTable).not.toHaveBeenCalled()
265+
})
152266
})

apps/sim/app/api/table/[tableId]/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab
7474
data: {
7575
table: {
7676
id: table.id,
77+
isVirtual: table.isVirtual,
7778
name: table.name,
7879
description: table.description,
7980
schema: {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { hybridAuthMockFns } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockCheckAccess, mockDeleteRow, mockUpdateRow } = vi.hoisted(() => ({
9+
mockCheckAccess: vi.fn(),
10+
mockDeleteRow: vi.fn(),
11+
mockUpdateRow: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/table', () => ({
15+
deleteRow: mockDeleteRow,
16+
updateRow: mockUpdateRow,
17+
}))
18+
19+
vi.mock('@/app/api/table/utils', () => ({
20+
accessError: (result: { status: number }) => new Response('denied', { status: result.status }),
21+
checkAccess: mockCheckAccess,
22+
rootErrorMessage: vi.fn(),
23+
rowWriteErrorResponse: vi.fn(),
24+
tableLockErrorResponse: vi.fn(),
25+
}))
26+
27+
import { DELETE, PATCH } from '@/app/api/table/[tableId]/rows/[rowId]/route'
28+
29+
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
30+
31+
describe('PATCH /api/table/[tableId]/rows/[rowId]', () => {
32+
beforeEach(() => {
33+
vi.clearAllMocks()
34+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
35+
success: true,
36+
userId: 'user-1',
37+
authType: 'session',
38+
})
39+
})
40+
41+
it('rejects synthetic Memory value writes with a read-only explanation', async () => {
42+
mockCheckAccess.mockResolvedValue({ ok: false, status: 423 })
43+
const tableId = `system_memory_${WORKSPACE_ID}`
44+
const response = await PATCH(
45+
new NextRequest(`http://localhost/api/table/${tableId}/rows/memory-1`, {
46+
method: 'PATCH',
47+
headers: { 'content-type': 'application/json' },
48+
body: JSON.stringify({ workspaceId: WORKSPACE_ID, data: { transcript: [] } }),
49+
}),
50+
{ params: Promise.resolve({ tableId, rowId: 'memory-1' }) }
51+
)
52+
53+
expect(response.status).toBe(423)
54+
expect(mockCheckAccess).toHaveBeenCalledWith(tableId, 'user-1', 'write')
55+
expect(mockUpdateRow).not.toHaveBeenCalled()
56+
})
57+
58+
it('rejects synthetic Memory row deletion through shared access', async () => {
59+
mockCheckAccess.mockResolvedValue({ ok: false, status: 423 })
60+
const tableId = `system_memory_${WORKSPACE_ID}`
61+
const response = await DELETE(
62+
new NextRequest(`http://localhost/api/table/${tableId}/rows/memory-1`, {
63+
method: 'DELETE',
64+
headers: { 'content-type': 'application/json' },
65+
body: JSON.stringify({ workspaceId: WORKSPACE_ID }),
66+
}),
67+
{ params: Promise.resolve({ tableId, rowId: 'memory-1' }) }
68+
)
69+
70+
expect(response.status).toBe(423)
71+
expect(mockCheckAccess).toHaveBeenCalledWith(tableId, 'user-1', 'write')
72+
expect(mockDeleteRow).not.toHaveBeenCalled()
73+
})
74+
})

0 commit comments

Comments
 (0)