Skip to content
Draft
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
2 changes: 2 additions & 0 deletions apps/sim/app/api/files/uploads/finalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { getWorkspaceFileSize, type StorageContext } from '@/lib/uploads/shared/types'
import { UploadSessionError, type UploadSessionRecord } from '@/lib/uploads/upload-session/service'
import { readWorkspaceFileUploadProvenance } from '@/lib/uploads/upload-session/workspace-file-provenance'
import { defaultFileDiscovery } from '@/lib/workspace-files/discovery'
import { toV2File } from '@/app/api/v2/files/utils'

export interface UploadActor {
Expand Down Expand Up @@ -376,6 +377,7 @@ async function insertOrLoadFileMetadata(
userId: input.userId,
workspaceId: input.workspaceId,
context: input.context,
discovery: defaultFileDiscovery(input.context),
originalName: input.originalName,
displayName: input.originalName,
contentType: input.contentType,
Expand Down
67 changes: 67 additions & 0 deletions apps/sim/app/api/table/[tableId]/analytics/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/** @vitest-environment node */
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { POST } from '@/app/api/table/[tableId]/analytics/route'

const mocks = vi.hoisted(() => ({ session: vi.fn(), execute: vi.fn(), limit: vi.fn() }))
vi.mock('@/lib/auth', () => ({ getSession: mocks.session }))
vi.mock('@/lib/core/rate-limiter', () => ({
enforceUserRateLimit: mocks.limit,
RateLimiter: class {},
}))
vi.mock('@/lib/table/application/analytics', () => ({
readTableAnalytics: { operation: { id: 'tables.rows.analytics' }, execute: mocks.execute },
}))
const context = { params: Promise.resolve({ tableId: 'tbl_test' }) }
const body = {
workspaceId: 'workspace_test',
query: {
from: '2026-09-01T00:00:00Z',
to: '2026-09-02T00:00:00Z',
aggregate: { n: { op: 'count' } },
},
}
const request = (value: unknown) =>
new NextRequest('http://localhost/api/table/tbl_test/analytics', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(value),
})
beforeEach(() => {
vi.clearAllMocks()
mocks.session.mockResolvedValue({ user: { id: 'viewer' }, session: { id: 'session' } })
mocks.limit.mockResolvedValue(null)
mocks.execute.mockResolvedValue({
rows: [{ n: 0 }],
columns: ['n'],
columnLabels: { n: 'n' },
truncated: false,
bucket: null,
})
})
describe('analytics HTTP adapter', () => {
it('authenticates before parsing and never uses a file share as authority', async () => {
mocks.session.mockResolvedValue(null)
expect((await POST(request({ invalid: true }), context)).status).toBe(401)
expect(mocks.execute).not.toHaveBeenCalled()
})
it('validates the contract before the use case', async () => {
expect(
(await POST(request({ ...body, query: { ...body.query, sql: 'select *' } }), context)).status
).toBe(400)
expect(mocks.execute).not.toHaveBeenCalled()
})
it('forwards the viewer and asserted scope and emits a private response', async () => {
const response = await POST(request(body), context)
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('private, no-store')
expect(await response.json()).toMatchObject({ rows: [{ n: 0 }], truncated: false })
expect(mocks.execute).toHaveBeenCalledWith(
expect.objectContaining({
principal: { kind: 'session', userId: 'viewer', sessionId: 'session' },
input: { tableId: 'tbl_test', assertedWorkspaceId: 'workspace_test', query: body.query },
})
)
expect(mocks.limit).toHaveBeenCalledWith('table-analytics', 'viewer', expect.any(Object))
})
})
28 changes: 28 additions & 0 deletions apps/sim/app/api/table/[tableId]/analytics/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { queryTableAnalyticsContract } from '@/lib/api/contracts/table-analytics'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { readTableAnalytics } from '@/lib/table/application/analytics'
import { tableOperations } from '@/lib/table/application/operations'

export const POST = defineInternalJsonRoute({
contract: queryTableAnalyticsContract,
auth: internalSessionAuth,
operation: tableOperations.analytics,
rateLimit: internalRateLimits.user({
bucketName: 'table-analytics',
config: { maxTokens: 120, refillRate: 60, refillIntervalMs: 60_000 },
}),
errorPolicy: internalOrchestrationErrorPolicy,
parseOptions: { maxBodyBytes: 64 * 1024 },
mapInput: ({ params, body }) => ({
tableId: params.tableId,
assertedWorkspaceId: body.workspaceId,
query: body.query,
}),
useCase: readTableAnalytics,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
})
3 changes: 2 additions & 1 deletion apps/sim/app/api/v2/chat/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,12 +409,13 @@ describe('POST /api/v2/chat', () => {
// CHAT pipeline now (persona + skills + CLI), not the persona-less execute surface.
expect(payload).toMatchObject({
message: 'hi',
mode: 'agent',
userId: 'user-1',
workspaceId: 'workspace-1',
chatId: SERVER_ISSUED_CHAT_ID,
integrationCatalog: { mcpServerIds: [] },
})
for (const legacy of ['messages', 'mode', 'isHosted', 'workspaceContext', 'userPermission']) {
for (const legacy of ['messages', 'isHosted', 'workspaceContext', 'userPermission']) {
expect(payload).not.toHaveProperty(legacy)
}
expect(options).toMatchObject({
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/v2/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ export const POST = withRouteHandler(
*/
const requestPayload: Record<string, unknown> = {
message,
mode: 'agent',
userId,
protocolVersion: PROTOCOL_VERSION,
workspaceId,
Expand Down
67 changes: 67 additions & 0 deletions apps/sim/app/api/workspaces/[id]/dashboards/[dashboardId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
deleteDashboardContract,
moveDashboardContract,
readDashboardContract,
updateDashboardContract,
} from '@/lib/api/contracts/dashboards'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import {
deleteDashboard,
moveDashboard,
readDashboard,
updateDashboard,
} from '@/lib/dashboards/application/dashboards'
import { dashboardOperations } from '@/lib/dashboards/application/operations'

export const GET = defineInternalJsonRoute({
contract: readDashboardContract,
auth: internalSessionAuth,
operation: dashboardOperations.read,
rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({ workspaceId: params.id, dashboardId: params.dashboardId }),
useCase: readDashboard,
})

export const PUT = defineInternalJsonRoute({
contract: updateDashboardContract,
auth: internalSessionAuth,
operation: dashboardOperations.update,
rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, body }) => ({
workspaceId: params.id,
dashboardId: params.dashboardId,
...body,
}),
useCase: updateDashboard,
})

export const PATCH = defineInternalJsonRoute({
contract: moveDashboardContract,
auth: internalSessionAuth,
operation: dashboardOperations.move,
rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, body }) => ({
workspaceId: params.id,
dashboardId: params.dashboardId,
...body,
}),
useCase: moveDashboard,
})

export const DELETE = defineInternalJsonRoute({
contract: deleteDashboardContract,
auth: internalSessionAuth,
operation: dashboardOperations.delete,
rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({ workspaceId: params.id, dashboardId: params.dashboardId }),
useCase: deleteDashboard,
})
59 changes: 59 additions & 0 deletions apps/sim/app/api/workspaces/[id]/dashboards/folders/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
createDashboardFolderContract,
deleteDashboardFolderContract,
listDashboardFoldersContract,
moveDashboardFolderContract,
} from '@/lib/api/contracts/dashboards'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import {
createDashboardFolder,
deleteDashboardFolder,
listDashboardFolders,
moveDashboardFolder,
} from '@/lib/dashboards/application/folders'
import { dashboardOperations } from '@/lib/dashboards/application/operations'

export const GET = defineInternalJsonRoute({
contract: listDashboardFoldersContract,
auth: internalSessionAuth,
operation: dashboardOperations.folders,
rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({ workspaceId: params.id }),
useCase: listDashboardFolders,
})

export const POST = defineInternalJsonRoute({
contract: createDashboardFolderContract,
auth: internalSessionAuth,
operation: dashboardOperations.createFolder,
rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }),
useCase: createDashboardFolder,
})

export const PATCH = defineInternalJsonRoute({
contract: moveDashboardFolderContract,
auth: internalSessionAuth,
operation: dashboardOperations.moveFolder,
rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }),
useCase: moveDashboardFolder,
})

export const DELETE = defineInternalJsonRoute({
contract: deleteDashboardFolderContract,
auth: internalSessionAuth,
operation: dashboardOperations.deleteFolder,
rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }),
useCase: deleteDashboardFolder,
})
29 changes: 29 additions & 0 deletions apps/sim/app/api/workspaces/[id]/dashboards/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createDashboardContract, listDashboardsContract } from '@/lib/api/contracts/dashboards'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { createDashboard, listDashboards } from '@/lib/dashboards/application/dashboards'
import { dashboardOperations } from '@/lib/dashboards/application/operations'

export const GET = defineInternalJsonRoute({
contract: listDashboardsContract,
auth: internalSessionAuth,
operation: dashboardOperations.list,
rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, query }) => ({ workspaceId: params.id, ...query }),
useCase: listDashboards,
})

export const POST = defineInternalJsonRoute({
contract: createDashboardContract,
auth: internalSessionAuth,
operation: dashboardOperations.create,
rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }),
useCase: createDashboard,
})
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ async function render(
<QueryClientProvider client={queryClient}>
<FeatureFlagsProvider
flags={{
dashboards: false,
'table-row-ttl': false,
'mothership-model-selector': mocks.advanced,
'mothership-plan-mode': mocks.plan,
Expand Down Expand Up @@ -298,6 +299,7 @@ it('keeps restored queued skills scoped when replacing a draft', async () => {
<QueryClientProvider client={queryClient}>
<FeatureFlagsProvider
flags={{
dashboards: false,
'table-row-ttl': false,
'mothership-model-selector': mocks.advanced,
'mothership-plan-mode': mocks.plan,
Expand Down
26 changes: 15 additions & 11 deletions apps/sim/app/o/[organizationId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
Expand Down Expand Up @@ -54,23 +55,26 @@ export default async function OrganizationLayout({
if (!context.mothershipAvailable && !context.searchAccess.memberScoped)
redirect(WORKSPACE_SETTINGS_PATH)

const [, tableRowTtlEnabled, modelSelectorEnabled, planModeEnabled] = await Promise.all([
prefetchOrganizationSidebar(
queryClient,
organizationId,
{ kind: 'session', userId: session.user.id, sessionId: session.session.id },
getActiveOrganizationId(session)
),
isTableRowTtlEnabled(),
isMothershipModelSelectorEnabled(),
isPlanModeEnabled(),
])
const [, tableRowTtlEnabled, modelSelectorEnabled, planModeEnabled, dashboardsEnabled] =
await Promise.all([
prefetchOrganizationSidebar(
queryClient,
organizationId,
{ kind: 'session', userId: session.user.id, sessionId: session.session.id },
getActiveOrganizationId(session)
),
isTableRowTtlEnabled(),
isMothershipModelSelectorEnabled(),
isPlanModeEnabled(),
isDashboardsEnabled(organizationId),
])
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<FeatureFlagsProvider
flags={{
dashboards: dashboardsEnabled,
'table-row-ttl': tableRowTtlEnabled,
'mothership-model-selector': modelSelectorEnabled,
'mothership-plan-mode': planModeEnabled,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ComponentType } from 'react'
import { Database, File as FileIcon, Table as TableIcon } from '@sim/emcn/icons'
import { ChartColumn, Database, File as FileIcon, Table as TableIcon } from '@sim/emcn/icons'
import type { FolderResourceType } from '@/lib/api/contracts/folders'
import { folderListHref } from '@/app/workspace/[workspaceId]/components/folders/search-params'

Expand All @@ -10,7 +10,7 @@ import { folderListHref } from '@/app/workspace/[workspaceId]/components/folders
*/
export type FolderedHeaderResourceType = Extract<
FolderResourceType,
'file' | 'knowledge_base' | 'table'
'file' | 'knowledge_base' | 'table' | 'dashboard'
>

export interface FolderedResourceHeaderMeta {
Expand All @@ -36,6 +36,7 @@ export const FOLDERED_RESOURCE_HEADERS: Record<
file: { rootLabel: 'Files', rootIcon: FileIcon, listSegment: 'files' },
knowledge_base: { rootLabel: 'Knowledge bases', rootIcon: Database, listSegment: 'knowledge' },
table: { rootLabel: 'Tables', rootIcon: TableIcon, listSegment: 'tables' },
dashboard: { rootLabel: 'Dashboards', rootIcon: ChartColumn, listSegment: 'dashboards' },
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { DashboardResource } from '@/components/dashboards/dashboard-resource'
export default async function DashboardPage({
params,
}: {
params: Promise<{ workspaceId: string; dashboardId: string }>
}) {
return <DashboardResource {...(await params)} />
}
Loading