Skip to content

Commit d4722f9

Browse files
v0.7.2: logs export security, code hygiene, mship cost attribution
2 parents 7ffc495 + 1a5cf49 commit d4722f9

72 files changed

Lines changed: 1312 additions & 1063 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/billing/update-cost/route.test.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { createMockRequest } from '@sim/testing'
4+
import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const {
@@ -16,6 +16,8 @@ const {
1616
mockCheckAndBillOverageThreshold: vi.fn(),
1717
}))
1818

19+
vi.mock('@sim/db', () => dbChainMock)
20+
1921
vi.mock('@/lib/copilot/request/http', () => ({
2022
checkInternalApiKey: mockCheckInternalApiKey,
2123
}))
@@ -47,10 +49,12 @@ import { POST } from '@/app/api/billing/update-cost/route'
4749
describe('POST /api/billing/update-cost — workspaceId attribution', () => {
4850
beforeEach(() => {
4951
vi.clearAllMocks()
52+
resetDbChainMock()
5053
mockCheckInternalApiKey.mockReturnValue({ success: true })
5154
mockRecordUsage.mockResolvedValue(undefined)
5255
mockRecordCumulativeUsage.mockResolvedValue({ billed: true, delta: 0.5, total: 0.5 })
5356
mockCheckAndBillOverageThreshold.mockResolvedValue(undefined)
57+
dbChainMockFns.limit.mockResolvedValue([{ id: 'ws-1' }])
5458
})
5559

5660
it('stamps workspaceId onto recorded usage when provided (no idempotency key)', async () => {
@@ -120,15 +124,45 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
120124
expect(mockCheckAndBillOverageThreshold).not.toHaveBeenCalled()
121125
})
122126

123-
it('rejects with 400 when workspaceId is omitted (contract-required, fail loud)', async () => {
127+
it('records unattributed when workspaceId is omitted (headless client)', async () => {
124128
const res = await POST(
125129
createMockRequest(
126130
'POST',
127131
{ userId: 'user-1', cost: 0.5, model: 'gpt', source: 'copilot' },
128132
{ 'x-api-key': 'internal' }
129133
)
130134
)
131-
expect(res.status).toBe(400)
132-
expect(mockRecordUsage).not.toHaveBeenCalled()
135+
expect(res.status).toBe(200)
136+
expect(dbChainMockFns.limit).not.toHaveBeenCalled()
137+
expect(mockRecordUsage).toHaveBeenCalledTimes(1)
138+
expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({
139+
userId: 'user-1',
140+
workspaceId: undefined,
141+
})
142+
})
143+
144+
it('records unattributed when the workspace does not exist in this deployment (self-hosted client)', async () => {
145+
dbChainMockFns.limit.mockResolvedValue([])
146+
const res = await POST(
147+
createMockRequest(
148+
'POST',
149+
{
150+
userId: 'user-1',
151+
cost: 0.5,
152+
model: 'claude-opus-4.8',
153+
source: 'workspace-chat',
154+
workspaceId: 'self-hosted-ws',
155+
idempotencyKey: 'msg-1-billing',
156+
},
157+
{ 'x-api-key': 'internal' }
158+
)
159+
)
160+
expect(res.status).toBe(200)
161+
expect(mockRecordCumulativeUsage).toHaveBeenCalledTimes(1)
162+
expect(mockRecordCumulativeUsage.mock.calls[0][0]).toMatchObject({
163+
userId: 'user-1',
164+
workspaceId: undefined,
165+
eventKey: 'update-cost:msg-1-billing',
166+
})
133167
})
134168
})

apps/sim/app/api/billing/update-cost/route.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { Span } from '@opentelemetry/api'
2+
import { db } from '@sim/db'
3+
import { workspace } from '@sim/db/schema'
24
import { createLogger } from '@sim/logger'
3-
import { toError } from '@sim/utils/errors'
5+
import { getPostgresConstraintName, getPostgresErrorCode, toError } from '@sim/utils/errors'
6+
import { eq } from 'drizzle-orm'
47
import { type NextRequest, NextResponse } from 'next/server'
58
import { billingUpdateCostContract } from '@/lib/api/contracts/subscription'
69
import { parseRequest } from '@/lib/api/server'
@@ -17,6 +20,35 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1720

1821
const logger = createLogger('BillingUpdateCostAPI')
1922

23+
/**
24+
* Resolves the request-supplied workspace to one that exists in this
25+
* deployment. Workspace attribution on the usage ledger is best-effort:
26+
* self-hosted and headless clients bill through this endpoint with workspace
27+
* IDs from their own databases, and `usage_log.workspace_id` carries an FK to
28+
* `workspace`, so stamping a foreign ID would fail the entire flush with an
29+
* FK violation and strand real cost in the caller's dead-letter queue.
30+
* Unknown workspaces are recorded unattributed instead — billing is keyed on
31+
* the user's billing entity and never depends on the workspace.
32+
*/
33+
async function resolveAttributableWorkspaceId(
34+
requestId: string,
35+
workspaceId: string | undefined
36+
): Promise<string | undefined> {
37+
if (!workspaceId) return undefined
38+
39+
const [row] = await db
40+
.select({ id: workspace.id })
41+
.from(workspace)
42+
.where(eq(workspace.id, workspaceId))
43+
.limit(1)
44+
if (row) return row.id
45+
46+
logger.warn(`[${requestId}] Workspace not found in this deployment; recording unattributed`, {
47+
workspaceId,
48+
})
49+
return undefined
50+
}
51+
2052
/**
2153
* POST /api/billing/update-cost
2254
* Update user cost with a pre-calculated cost value (internal API key auth required)
@@ -129,6 +161,8 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
129161
source,
130162
})
131163

164+
const attributedWorkspaceId = await resolveAttributableWorkspaceId(requestId, workspaceId)
165+
132166
// Go sends the request's CUMULATIVE cost, possibly more than once (a
133167
// mid-loop provider-error flush, then the recovered terminal flush, plus
134168
// abort-race duplicates). Record it as a monotonic top-up: one ledger row
@@ -141,7 +175,7 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
141175
if (idempotencyKey) {
142176
const result = await recordCumulativeUsage({
143177
userId,
144-
workspaceId,
178+
workspaceId: attributedWorkspaceId,
145179
source,
146180
model,
147181
cost,
@@ -160,7 +194,7 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
160194
} else {
161195
await recordUsage({
162196
userId,
163-
workspaceId,
197+
workspaceId: attributedWorkspaceId,
164198
entries: [
165199
{
166200
category: 'model',
@@ -229,8 +263,16 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
229263
} catch (error) {
230264
const duration = Date.now() - startTime
231265

266+
// Surface the underlying Postgres failure (e.g. 23503 FK violation vs a
267+
// lock timeout) — Drizzle's "Failed query" wrapper alone cannot
268+
// distinguish them, which made the dead-workspace incident undiagnosable
269+
// from logs.
270+
const pgCode = getPostgresErrorCode(error)
271+
const pgConstraint = getPostgresConstraintName(error)
232272
logger.error(`[${requestId}] Cost update failed`, {
233273
error: toError(error).message,
274+
...(pgCode && { pgCode }),
275+
...(pgConstraint && { pgConstraint }),
234276
stack: error instanceof Error ? error.stack : undefined,
235277
duration,
236278
})

apps/sim/app/api/logs/export/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { and, desc, eq, sql } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { getSession } from '@/lib/auth'
77
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
8+
import { neutralizeCsvFormula } from '@/lib/core/utils/csv'
89
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
910
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
1011
import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters'
@@ -16,7 +17,7 @@ export const revalidate = 0
1617

1718
function escapeCsv(value: any): string {
1819
if (value === null || value === undefined) return ''
19-
const str = String(value)
20+
const str = typeof value === 'string' ? neutralizeCsvFormula(value) : String(value)
2021
if (/[",\n]/.test(str)) {
2122
return `"${str.replace(/"/g, '""')}"`
2223
}

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

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { tableExportFormatSchema, tableIdParamsSchema } from '@/lib/api/contracts/tables'
44
import { getValidationErrorMessage } from '@/lib/api/server'
55
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
6+
import { neutralizeCsvFormula } from '@/lib/core/utils/csv'
67
import { generateRequestId } from '@/lib/core/utils/request'
78
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
89
import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys'
@@ -119,14 +120,6 @@ function sanitizeFilename(name: string): string {
119120
return cleaned || 'table'
120121
}
121122

122-
/**
123-
* Prefixes a single quote to values starting with a spreadsheet formula trigger
124-
* (`=`, `+`, `-`, `@`, tab, CR), neutralizing CSV injection in Excel/Sheets.
125-
*/
126-
function neutralizeCsvFormula(value: string): string {
127-
return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value
128-
}
129-
130123
/**
131124
* Serializes a cell for CSV. Only string cells are formula-neutralized; numbers,
132125
* booleans, dates, and JSON objects can never form a trigger and pass through verbatim.

apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
toast,
1818
} from '@/components/emcn'
1919
import { cn } from '@/lib/core/utils/cn'
20+
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
2021
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
2122
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
2223
import { useFolderStore } from '@/stores/folders/store'
@@ -49,21 +50,20 @@ const BUTTON_CLASS =
4950

5051
interface MessageActionsProps {
5152
content: string
52-
chatId?: string
5353
userQuery?: string
5454
requestId?: string
5555
messageId?: string
5656
}
5757

5858
export const MessageActions = memo(function MessageActions({
5959
content,
60-
chatId,
6160
userQuery,
6261
requestId,
6362
messageId,
6463
}: MessageActionsProps) {
6564
const router = useRouter()
6665
const params = useParams<{ workspaceId: string }>()
66+
const { chatId } = useChatSurface()
6767
const [copied, setCopied] = useState(false)
6868
const [copiedRequestId, setCopiedRequestId] = useState(false)
6969
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
'use client'
2+
3+
import {
4+
createContext,
5+
type ReactNode,
6+
useCallback,
7+
useContext,
8+
useLayoutEffect,
9+
useMemo,
10+
useRef,
11+
} from 'react'
12+
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
13+
import type { ChatContext } from '@/stores/panel'
14+
15+
/**
16+
* Identity and interaction callbacks shared across a Mothership chat surface
17+
* (home conversation view, home initial view, copilot panel). Carried via
18+
* context so leaf components (UserInput, MessageContent, MessageActions) can
19+
* consume them without relaying through every intermediate component.
20+
*/
21+
interface ChatSurfaceContextValue {
22+
/** Resolved id of the chat backing this surface, if one exists yet. */
23+
chatId?: string
24+
/** Id of the user interacting with this surface. */
25+
userId?: string
26+
/** Notifies the surface owner that a context chip was added to the input. */
27+
onContextAdd: (context: ChatContext) => void
28+
/** Notifies the surface owner that a context chip was removed from the input. */
29+
onContextRemove: (context: ChatContext) => void
30+
/** Opens a workspace resource referenced from rendered message content. */
31+
onWorkspaceResourceSelect: (resource: MothershipResource) => void
32+
}
33+
34+
const noop = () => {}
35+
36+
const ChatSurfaceContext = createContext<ChatSurfaceContextValue>({
37+
onContextAdd: noop,
38+
onContextRemove: noop,
39+
onWorkspaceResourceSelect: noop,
40+
})
41+
42+
interface ChatSurfaceProviderProps {
43+
chatId?: string
44+
userId?: string
45+
onContextAdd?: (context: ChatContext) => void
46+
onContextRemove?: (context: ChatContext) => void
47+
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
48+
children: ReactNode
49+
}
50+
51+
/**
52+
* Provides the chat-surface identity and interaction callbacks to descendants.
53+
* Callbacks are latched in refs and exposed as stable wrappers so the memoized
54+
* context value only changes when `chatId` or `userId` change — consumers do
55+
* not re-render when a parent re-creates a handler.
56+
*/
57+
export function ChatSurfaceProvider({
58+
chatId,
59+
userId,
60+
onContextAdd,
61+
onContextRemove,
62+
onWorkspaceResourceSelect,
63+
children,
64+
}: ChatSurfaceProviderProps) {
65+
const onContextAddRef = useRef(onContextAdd)
66+
const onContextRemoveRef = useRef(onContextRemove)
67+
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)
68+
69+
useLayoutEffect(() => {
70+
onContextAddRef.current = onContextAdd
71+
onContextRemoveRef.current = onContextRemove
72+
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect
73+
})
74+
75+
const stableOnContextAdd = useCallback((context: ChatContext) => {
76+
onContextAddRef.current?.(context)
77+
}, [])
78+
const stableOnContextRemove = useCallback((context: ChatContext) => {
79+
onContextRemoveRef.current?.(context)
80+
}, [])
81+
const stableOnWorkspaceResourceSelect = useCallback((resource: MothershipResource) => {
82+
onWorkspaceResourceSelectRef.current?.(resource)
83+
}, [])
84+
85+
const value = useMemo<ChatSurfaceContextValue>(
86+
() => ({
87+
chatId,
88+
userId,
89+
onContextAdd: stableOnContextAdd,
90+
onContextRemove: stableOnContextRemove,
91+
onWorkspaceResourceSelect: stableOnWorkspaceResourceSelect,
92+
}),
93+
[chatId, userId, stableOnContextAdd, stableOnContextRemove, stableOnWorkspaceResourceSelect]
94+
)
95+
96+
return <ChatSurfaceContext.Provider value={value}>{children}</ChatSurfaceContext.Provider>
97+
}
98+
99+
/**
100+
* Reads the surrounding chat surface. Outside a provider this returns no-op
101+
* callbacks and undefined identity, matching the previous optional-prop
102+
* behavior.
103+
*/
104+
export function useChatSurface(): ChatSurfaceContextValue {
105+
return useContext(ChatSurfaceContext)
106+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ChatSurfaceProvider, useChatSurface } from './chat-surface-context'

apps/sim/app/workspace/[workspaceId]/home/components/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
export { ChatMessageAttachments } from './chat-message-attachments'
2+
export { ChatSurfaceProvider, useChatSurface } from './chat-surface-context'
23
export { ContextMentionIcon } from './context-mention-icon'
34
export { CreditsChip } from './credits-chip'
45
export {
56
assistantMessageHasRenderableContent,
67
MessageContent,
78
} from './message-content'
89
export { MothershipChat } from './mothership-chat'
10+
export {
11+
MothershipResourcesProvider,
12+
useMothershipResources,
13+
} from './mothership-resources-context'
914
export { MothershipView } from './mothership-view'
1015
export { QueuedMessages } from './queued-messages'
1116
export { SuggestedActions } from './suggested-actions'

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-ca
66
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
77
import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils'
88
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state'
9-
import type { ContentBlock, MothershipResource, OptionItem, ToolCallData } from '../../types'
9+
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
10+
import type { ContentBlock, OptionItem, ToolCallData } from '../../types'
1011
import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types'
1112
import type { AgentGroupItem } from './components'
1213
import {
@@ -676,16 +677,15 @@ interface MessageContentProps {
676677
fallbackContent: string
677678
isStreaming: boolean
678679
onOptionSelect?: (id: string) => void
679-
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
680680
}
681681

682682
function MessageContentInner({
683683
blocks,
684684
fallbackContent,
685685
isStreaming = false,
686686
onOptionSelect,
687-
onWorkspaceResourceSelect,
688687
}: MessageContentProps) {
688+
const { onWorkspaceResourceSelect } = useChatSurface()
689689
const parsed = useMemo(() => (blocks.length > 0 ? parseBlocks(blocks) : []), [blocks])
690690

691691
const segments: MessageSegment[] =

0 commit comments

Comments
 (0)