diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 8710553e800..2cff0545957 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -1,6 +1,9 @@ --- paths: - "apps/sim/app/workspace/*/settings/**" + - "apps/sim/app/workspace/*/{integrations,skills,upgrade}/**" + - "apps/sim/app/workspace/*/components/{resource-tile,credential-detail}/**" + - "apps/sim/components/{settings,permissions}/**" - "apps/sim/ee/**/components/**" --- @@ -20,7 +23,7 @@ Do NOT hand-roll any of these in a settings page — they are owned by the layou shell (fed through `SettingsPanel`): - `
` shell -- the header bar (`flex flex-shrink-0 … px-[16px] pt-[8.5px] pb-[8.5px]`) +- the header bar — compose `PAGE_HEADER_BAR` (`@/components/page-header-bar`); never rewrite its padding - the scroll container (`min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]`) - the content column (`mx-auto … max-w-[48rem] … gap-7`) - a title block (`

` + `

`) @@ -55,21 +58,23 @@ return ( ## `SettingsPanel` props - `actions?: SettingsAction[]` — right-aligned header chips, **data only**: - `{ text, icon?, variant?: 'primary'|'destructive', active?, onSelect, disabled?, tooltip? }`. + `{ id?, text, textTone?: 'error', icon?, variant?: 'primary'|'destructive', active?, + onSelect, onPrefetch?, disabled?, tooltip? }`. The shell renders each as a `Chip` — never pass JSX, a `

`, or `className` (the locked contract: it's structurally impossible to vibe-code a padding change). Multiple/conditional actions are a plain array (`[...(canManage ? [{…}] : []), …]`). Labels are **sentence case** (`Add override`, not `Add Override`). A disabled action that needs to explain itself sets - `tooltip` (the shell renders the hover tooltip, disabled chip included) — never - hand-roll a tooltip-wrapped chip in `aside`. Save/Discard pairs come from the - `saveDiscardActions()` helper (spread it into `actions`). Only a widget that - genuinely cannot be a chip (e.g. one needing hover-prefetch) goes in `aside`. + `tooltip` (the shell renders the hover tooltip, disabled chip included). An action + that wants to warm a route on hover sets `onPrefetch`; the shell wires it. A label + that flips while pending (`Delete` → `Deleting...`) sets a stable `id`, or the chip + remounts mid-action. Save/Discard pairs come from the `saveDiscardActions()` + helper (spread it into `actions`). - `back?: SettingsBackAction` (`{ text, icon?, onSelect }`) — left-aligned back chip for a **detail sub-view** (e.g. a selected MCP server, a permission group, a retention policy). Detail sub-views render through `SettingsPanel` like list pages — they do NOT hand-roll their own shell. -- `aside?: ReactNode` — escape hatch for the rare non-chip header widget. Keep it rare. +- `docsLink?: string` — renders the header's `Docs` `ChipLink`. - `search?: { value; onChange: (value: string) => void; placeholder?; disabled? }` — renders the canonical search field directly below the title. Pass `setSearchTerm` straight to `onChange`. Use this for a standalone search; if search shares a row @@ -82,15 +87,15 @@ return ( ## Title + description live in navigation metadata -`apps/sim/app/workspace/[workspaceId]/settings/navigation.ts` is the single source -of truth. Every `NavigationItem` carries a one-line `description`; `SettingsPanel` -resolves both via `getSettingsSectionMeta(section)` and the +`apps/sim/components/settings/navigation.ts` is the single source of truth (the +`settings/navigation.ts` in the route tree is only a re-export shim). Every `NavigationItem` carries a one-line `description`; `SettingsPanel` +resolves both via `getSettingsSectionMeta(plane, section)` and the `SettingsSectionProvider` the settings shell wraps around the active section. Adding a new settings page: -1. Add the `SettingsSection` id + a `NavigationItem` (with `label` **and** - `description`) in `navigation.ts`. Keep descriptions verb-first, one line, +1. Add the section id to the `UnifiedSettingsSection` union + a `NavigationItem` + (with `label` **and** `description`) in `components/settings/navigation.ts`. Keep descriptions verb-first, one line, ~40–55 chars, in the product voice (see `.claude/rules/constitution.md`). 2. Render the component inside the shell's `effectiveSection` switch in `settings/[section]/settings.tsx`. @@ -107,19 +112,14 @@ token (if the pixel value matches one exactly) or a sign the page never migrated grep `text-\[1[0-8]px\]` under `apps/sim/app/workspace/*/settings/**` and `apps/sim/ee/**` to find stragglers. -For a two-line list row (title/value on top, a muted subtitle below — a name + -email, a tool name + description, a server name + status), the established -pairing is: +Watch `text-xs`: it is 11px here, so a "caption" written as `text-xs` is a pixel +short. See `sim-styling.md` for the full scale. -- **Title / row value**: `text-[var(--text-body)] text-sm` -- **Subtitle / muted description**: `text-[var(--text-muted)] text-caption` - -This is not a stylistic guess — it is the tokenized form of the literal-pixel -pairing (`text-[14px] text-[var(--text-body)]` / `text-[12px] -text-[var(--text-muted)]`) already used for this exact row shape across -`member-list.tsx`, `api-keys.tsx`, `mcp.tsx`, `billing.tsx`, -`workflow-mcp-servers.tsx`, and others — keep new rows consistent with it rather -than inventing a new size pairing. +The two-line list row (title over a muted subtitle — a name + email, a tool name ++ description, a server name + status) is **not something you build**: it is +`SettingsResourceRow`, which owns the pairing +(`text-[var(--text-body)] text-sm` over `text-[var(--text-muted)] text-caption`). +See "The resource row" below. For a toggle row (a `Switch` with a title and optional description), use the emcn `Label` component for the title — never a hand-rolled `` — paired with @@ -145,21 +145,159 @@ independently-defined tokens (not interchangeable — they resolve to different colors) and both see legitimate use across settings pages; this rule only pins down the **row title/subtitle** shape above, not every text element on every page. +## The resource row + +**`SettingsResourceRow`** (`…/components/settings-resource-row`) is *the* list row +for every settings resource — and for skills, integrations, and the `ee/` surfaces +too. It owns the tile, the title/subtitle tokens, the row padding and bleed +(`-mx-2 … rounded-lg p-2`), the hit area, the focus ring, the navigation chevron, +and — on activatable rows only — the hover band. Never hand-roll any of it, and never wrap the row in your own +`
{(aside || (actions && actions.length > 0)) && ( -
+
{aside} - {actions?.map((action) => ( + {orderHeaderActions(actions).map(({ action }) => ( --disable-console-intercept + */ +import { Editor } from '@tiptap/core' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { Awareness } from 'y-protocols/awareness' +import * as Y from 'yjs' +import { createMarkdownEditorExtensions } from '../editor-extensions' +import { parseMarkdownToDoc } from '../markdown-parse' +import { applyAgentStreamFrame, beginAgentStream, endAgentStream } from './apply-streamed-markdown' + +beforeAll(() => { + if (!document.elementFromPoint) document.elementFromPoint = () => null +}) + +function makeCollabEditor() { + const doc = new Y.Doc() + const awareness = new Awareness(doc) + const editor = new Editor({ + extensions: createMarkdownEditorExtensions({ + placeholder: '', + collaboration: { doc, awareness, user: { name: 'U', color: '#fff', clientId: doc.clientID } }, + }), + content: '', + }) + return { editor, doc, awareness } +} + +const teardown: Array<() => void> = [] +afterEach(() => { + for (const fn of teardown.splice(0)) fn() +}) +function track(t: { editor: Editor; doc: Y.Doc; awareness: Awareness }) { + teardown.push(() => { + t.editor.destroy() + t.awareness.destroy() + t.doc.destroy() + }) + return t +} + +/** Wire two Y.Docs as real peers: forward each update to the other, origin-guarded to avoid echo. */ +function wirePeers(a: Y.Doc, b: Y.Doc) { + const A2B = Symbol('a->b') + const B2A = Symbol('b->a') + a.on('update', (u: Uint8Array, origin: unknown) => { + if (origin !== B2A) Y.applyUpdate(b, u, A2B) + }) + b.on('update', (u: Uint8Array, origin: unknown) => { + if (origin !== A2B) Y.applyUpdate(a, u, B2A) + }) +} + +/** Seed editor A with markdown (through the real parse), then bring up B as a synced peer. */ +function seededPair(markdown: string) { + const A = track(makeCollabEditor()) + A.editor.commands.setContent(parseMarkdownToDoc(markdown), { contentType: 'json' }) + const B = track(makeCollabEditor()) + Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(A.doc)) + wirePeers(A.doc, B.doc) + return { A, B } +} + +/** A peer edit: insert `text` at the start of the first text node containing `needle`. */ +function peerInsertNear(editor: Editor, needle: string, text: string): boolean { + let pos: number | null = null + editor.state.doc.descendants((node, p) => { + if (pos !== null) return false + if (node.isText && node.text?.includes(needle)) pos = p + node.text.indexOf(needle) + }) + if (pos === null) return false + return editor.commands.insertContentAt(pos, text) +} + +function fragStr(doc: Y.Doc): string { + return doc.getXmlFragment('default').toString() +} +function count(hay: string, needle: string): number { + return hay.split(needle).length - 1 +} +function emptyParas(editor: Editor): number { + let n = 0 + editor.state.doc.descendants((node) => { + if (node.type.name === 'paragraph' && node.childCount === 0) n++ + }) + return n +} + +describe('two-writer: peer edits while the agent streams', () => { + it('SANITY: peers converge on seed and a plain peer edit with no agent activity', () => { + const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta') + expect(peerInsertNear(B.editor, 'Alpha', 'PEER ')).toBe(true) + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) + expect(A.editor.state.doc.textContent).toContain('PEER Alpha') + }) + + it('NON-OVERLAPPING: agent appends at the bottom while the peer edits the top — peer edit MUST survive', () => { + const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta') + const session = beginAgentStream(A.editor)! + + // Frame 1: agent appends Gamma (region far from the peer's target). + applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nBeta\n\nGamma') + // Peer edits the TOP paragraph mid-stream (the agent never touches or knows about this). + expect(peerInsertNear(B.editor, 'Alpha', 'PEER ')).toBe(true) + // Frames 2-3: agent keeps appending. Its bodies say "Alpha" (no PEER) — the test is whether the + // (aggressive) updateYFragment re-emits/clobbers the unchanged Alpha paragraph. + applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta') + applyAgentStreamFrame( + A.editor, + session, + '# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta\n\nEpsilon' + ) + endAgentStream(session) + + const textA = A.editor.state.doc.textContent + console.log(`\n[NON-OVERLAP] A: ${JSON.stringify(textA)}`) + console.log( + `[NON-OVERLAP] converged=${fragStr(A.doc) === fragStr(B.doc)} peerCount=${count(textA, 'PEER ')} emptyParas=${emptyParas(A.editor)}` + ) + + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // CRDT convergence + expect(count(textA, 'PEER ')).toBe(1) // peer edit survives, exactly once (no clobber, no dup) + expect(textA).toContain('Epsilon') // agent's stream landed + expect(textA).toContain('Beta') // untouched content intact + expect(emptyParas(A.editor)).toBe(0) // no stray empties from the merge + }) + + it('POSITION DRIFT: agent inserts a paragraph ABOVE while the peer edits the paragraph BELOW', () => { + // The exact scenario relative-position anchoring is meant to protect: the agent shifts positions by + // inserting content above the region the peer is editing. Without anchoring, an offset-based writer + // would misplace the edit; a whole-doc CRDT diff should not. + const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta') + const session = beginAgentStream(A.editor)! + + applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nMIDDLE\n\nBeta') + // Peer edits Beta, which just shifted down by the agent's inserted MIDDLE paragraph. + expect(peerInsertNear(B.editor, 'Beta', 'PEER ')).toBe(true) + applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nMIDDLE\n\nMIDDLE2\n\nBeta') + endAgentStream(session) + + const textA = A.editor.state.doc.textContent + console.log(`\n[POS-DRIFT] A: ${JSON.stringify(textA)}`) + console.log( + `[POS-DRIFT] converged=${fragStr(A.doc) === fragStr(B.doc)} peerCount=${count(textA, 'PEER ')} peerOnBeta=${textA.includes('PEER Beta')} emptyParas=${emptyParas(A.editor)}` + ) + + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // convergence + expect(count(textA, 'PEER ')).toBe(1) // no duplication + expect(textA).toContain('PEER Beta') // peer edit stayed attached to Beta despite the insert above + expect(textA).toContain('MIDDLE2') // agent's inserts landed + expect(emptyParas(A.editor)).toBe(0) + }) + + it('OVERLAPPING: agent rewrites the exact paragraph the peer is editing (diagnostic + must converge)', () => { + const { A, B } = seededPair('# Title\n\noriginal body text') + const session = beginAgentStream(A.editor)! + + applyAgentStreamFrame(A.editor, session, '# Title\n\noriginal body text extended') + // Peer edits the SAME paragraph the agent is rewriting. + expect(peerInsertNear(B.editor, 'original', 'PEER ')).toBe(true) + applyAgentStreamFrame(A.editor, session, '# Title\n\nagent fully rewrote this paragraph') + endAgentStream(session) + + const textA = A.editor.state.doc.textContent + console.log(`\n[OVERLAP] A: ${JSON.stringify(textA)}`) + console.log( + `[OVERLAP] converged=${fragStr(A.doc) === fragStr(B.doc)} peerSurvived=${textA.includes('PEER')} emptyParas=${emptyParas(A.editor)}` + ) + + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // convergence is non-negotiable even in conflict + expect(emptyParas(A.editor)).toBe(0) // conflict must not leave stray empty paragraphs + // peer survival here is CRDT-dependent — reported above, not hard-asserted. + }) + + it('FULL REWRITE: peer edits original content that the agent then deletes in a full rewrite', () => { + const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta\n\nGamma') + const session = beginAgentStream(A.editor)! + + // Peer edits Beta WHILE it still exists — genuinely concurrent with the impending rewrite. + // (Asserting the insert landed guards against a false-green where the target was already gone.) + expect(peerInsertNear(B.editor, 'Beta', 'PEER ')).toBe(true) + // Agent replaces the WHOLE doc across two frames, deleting Alpha/Beta/Gamma. + applyAgentStreamFrame(A.editor, session, '# Report\n\nOne\n\nTwo') + applyAgentStreamFrame(A.editor, session, '# Report\n\nOne\n\nTwo\n\nThree') + endAgentStream(session) + + const textA = A.editor.state.doc.textContent + console.log(`\n[FULL-REWRITE] A: ${JSON.stringify(textA)}`) + console.log( + `[FULL-REWRITE] converged=${fragStr(A.doc) === fragStr(B.doc)} peerCount=${count(textA, 'PEER ')} oneCount=${count(textA, 'One')} threeCount=${count(textA, 'Three')} emptyParas=${emptyParas(A.editor)}` + ) + + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // convergence + expect(count(textA, 'One')).toBe(1) // agent content not duplicated by the concurrent merge + expect(count(textA, 'Three')).toBe(1) + expect(emptyParas(A.editor)).toBe(0) // no stray empties from a delete/insert conflict + // The peer's insert is NOT lost when the rewrite deletes its surrounding paragraph: Yjs preserves + // the inserted text and reattaches it to the nearest surviving anchor (it relocates into the + // rewritten content rather than vanishing). What matters is that it survives exactly once — never + // duplicated, never silently dropped. + expect(count(textA, 'PEER ')).toBe(1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/collab-streaming-integration.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/collab-streaming-integration.test.ts new file mode 100644 index 00000000000..b8996800923 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/collab-streaming-integration.test.ts @@ -0,0 +1,200 @@ +/** + * @vitest-environment jsdom + * + * Integration coverage for the collaborative agent-streaming surface with all the moving pieces: + * multiple peers, undo isolation, the durable persist→reopen round-trip, empty-collapse on the live + * streaming path, and a late joiner. Editors are wired as genuine Yjs peers (mesh update forwarding). + * This exercises the CRDT/merge/convert LOGIC deterministically; it does NOT cover the realtime socket + * transport, RAF-paced stream loop, or real browser timing (those need a live 2-browser E2E harness). + * Run: bunx vitest run --disable-console-intercept + */ +import { Editor } from '@tiptap/core' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { Awareness } from 'y-protocols/awareness' +import * as Y from 'yjs' +import { markdownToYDoc, yDocToMarkdown } from '@/lib/collab-doc/converter' +import { createMarkdownEditorExtensions } from '../editor-extensions' +import { parseMarkdownToDoc } from '../markdown-parse' +import { applyAgentStreamFrame, beginAgentStream, endAgentStream } from './apply-streamed-markdown' + +beforeAll(() => { + if (!document.elementFromPoint) document.elementFromPoint = () => null +}) + +const teardown: Array<() => void> = [] +afterEach(() => { + for (const fn of teardown.splice(0)) fn() +}) + +function makeCollabEditor() { + const doc = new Y.Doc() + const awareness = new Awareness(doc) + const editor = new Editor({ + extensions: createMarkdownEditorExtensions({ + placeholder: '', + collaboration: { doc, awareness, user: { name: 'U', color: '#fff', clientId: doc.clientID } }, + }), + content: '', + }) + const t = { editor, doc, awareness } + teardown.push(() => { + editor.destroy() + awareness.destroy() + doc.destroy() + }) + return t +} + +/** Forward every local/agent update from each doc to all others (origin-guarded), a full mesh. */ +function wireMesh(docs: Y.Doc[]) { + const MESH = Symbol('mesh') + for (const d of docs) { + d.on('update', (u: Uint8Array, origin: unknown) => { + if (origin === MESH) return + for (const other of docs) if (other !== d) Y.applyUpdate(other, u, MESH) + }) + } +} + +function peerInsertNear(editor: Editor, needle: string, text: string): boolean { + let pos: number | null = null + editor.state.doc.descendants((node, p) => { + if (pos !== null) return false + if (node.isText && node.text?.includes(needle)) pos = p + node.text.indexOf(needle) + }) + if (pos === null) return false + return editor.commands.insertContentAt(pos, text) +} + +const fragStr = (doc: Y.Doc) => doc.getXmlFragment('default').toString() +const countText = (hay: string, needle: string) => hay.split(needle).length - 1 +function emptyParas(editor: Editor): number { + let n = 0 + editor.state.doc.descendants((node) => { + if (node.type.name === 'paragraph' && node.childCount === 0) n++ + }) + return n +} + +describe('collab streaming integration — moving pieces', () => { + it('THREE-WAY: agent + two peers editing different regions all converge, both peer edits survive', () => { + const A = makeCollabEditor() + A.editor.commands.setContent(parseMarkdownToDoc('# Title\n\nAlpha\n\nBeta\n\nGamma'), { + contentType: 'json', + }) + const B = makeCollabEditor() + const C = makeCollabEditor() + Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(A.doc)) + Y.applyUpdate(C.doc, Y.encodeStateAsUpdate(A.doc)) + wireMesh([A.doc, B.doc, C.doc]) + + const session = beginAgentStream(A.editor)! + applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta') + expect(peerInsertNear(B.editor, 'Alpha', 'B_EDIT ')).toBe(true) // peer B edits the top + expect(peerInsertNear(C.editor, 'Gamma', 'C_EDIT ')).toBe(true) // peer C edits the bottom + applyAgentStreamFrame( + A.editor, + session, + '# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta\n\nEpsilon' + ) + endAgentStream(session) + + const textA = A.editor.state.doc.textContent + console.log(`\n[3-WAY] A: ${JSON.stringify(textA)}`) + console.log( + `[3-WAY] converged=${fragStr(A.doc) === fragStr(B.doc) && fragStr(B.doc) === fragStr(C.doc)} B_EDIT=${countText(textA, 'B_EDIT ')} C_EDIT=${countText(textA, 'C_EDIT ')} empty=${emptyParas(A.editor)}` + ) + + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) + expect(fragStr(B.doc)).toBe(fragStr(C.doc)) + expect(countText(textA, 'B_EDIT ')).toBe(1) + expect(countText(textA, 'C_EDIT ')).toBe(1) + expect(textA).toContain('Epsilon') + expect(emptyParas(A.editor)).toBe(0) + }) + + it('UNDO ISOLATION: a peer undo reverts only the peer’s own edit, never the agent’s stream', () => { + const A = makeCollabEditor() + A.editor.commands.setContent(parseMarkdownToDoc('# Title\n\nbase'), { contentType: 'json' }) + const B = makeCollabEditor() + Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(A.doc)) + wireMesh([A.doc, B.doc]) + + expect(peerInsertNear(B.editor, 'base', 'PEER_UNDOABLE ')).toBe(true) // peer's own edit (undo stack) + const session = beginAgentStream(A.editor)! + applyAgentStreamFrame( + A.editor, + session, + '# Title\n\nPEER_UNDOABLE base\n\nagent added this line' + ) + endAgentStream(session) + + const undid = B.editor.commands.undo() + const textB = B.editor.state.doc.textContent + console.log(`\n[UNDO] undoRan=${undid} afterUndo=${JSON.stringify(textB)}`) + + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // still converged after undo + expect(textB).toContain('agent added this line') // agent content NOT undone by the peer + expect(textB).not.toContain('PEER_UNDOABLE') // peer's own edit was undone + }) + + it('PERSIST ROUND-TRIP: stream → serialize to durable markdown → reopen yields the same content, no empties', () => { + const A = makeCollabEditor() + const session = beginAgentStream(A.editor)! + applyAgentStreamFrame(A.editor, session, '# Report\n\n## Section 1\n\nbody one') + applyAgentStreamFrame( + A.editor, + session, + '# Report\n\n## Section 1\n\nbody one\n\n## Section 2\n\nbody two' + ) + endAgentStream(session) + + const durable = yDocToMarkdown(A.doc) // server-side projection to durable markdown + const reopened = markdownToYDoc(durable) // cold reopen from durable + const reopenedMd = yDocToMarkdown(reopened) + const blankRuns = (durable.match(/\n{3,}/g) ?? []).length + console.log(`\n[ROUND-TRIP] durable=${JSON.stringify(durable)}`) + console.log(`[ROUND-TRIP] reopenStable=${reopenedMd === durable} blankRuns=${blankRuns}`) + + expect(durable).toContain('Section 1') + expect(durable).toContain('Section 2') + expect(durable).toContain('body two') + expect(blankRuns).toBe(0) // no pathological blank runs in the persisted markdown + expect(reopenedMd).toBe(durable) // reopen is a fixed point (stable) + reopened.destroy() + }) + + it('EMPTY-COLLAPSE ON THE STREAM PATH: an agent body with a huge blank run does not strand empties', () => { + const A = makeCollabEditor() + A.editor.commands.setContent(parseMarkdownToDoc('# Title\n\nintro'), { contentType: 'json' }) + const session = beginAgentStream(A.editor)! + // The agent emits a pathological blank run between two blocks (the original incident's shape). + applyAgentStreamFrame(A.editor, session, `# Title\n\nintro${'\n'.repeat(400)}tail paragraph`) + endAgentStream(session) + + console.log( + `\n[STREAM-COLLAPSE] text=${JSON.stringify(A.editor.state.doc.textContent)} empty=${emptyParas(A.editor)}` + ) + expect(emptyParas(A.editor)).toBe(0) // collapse protects the live streaming path, not just static open + expect(A.editor.state.doc.textContent).toContain('tail paragraph') + }) + + it('LATE JOINER: a peer that syncs AFTER the stream sees the full, clean document', () => { + const A = makeCollabEditor() + A.editor.commands.setContent(parseMarkdownToDoc('# Doc\n\nstart'), { contentType: 'json' }) + const session = beginAgentStream(A.editor)! + applyAgentStreamFrame(A.editor, session, '# Doc\n\nstart\n\nstreamed body') + endAgentStream(session) + + // A brand-new client joins now and syncs from the current state. + const D = makeCollabEditor() + Y.applyUpdate(D.doc, Y.encodeStateAsUpdate(A.doc)) + + console.log( + `\n[LATE-JOIN] D: ${JSON.stringify(D.editor.state.doc.textContent)} converged=${fragStr(A.doc) === fragStr(D.doc)}` + ) + expect(fragStr(A.doc)).toBe(fragStr(D.doc)) + expect(D.editor.state.doc.textContent).toContain('streamed body') + expect(emptyParas(D.editor)).toBe(0) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts index 94511d5ebad..43466f49861 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts @@ -172,9 +172,14 @@ function stripEmptyListItemLines(markdown: string): string { * Cleans up serializer output: drops empty list-item marker lines that would otherwise corrupt on * round-trip ({@link stripEmptyListItemLines}), restores callout markers the serializer * backslash-escapes (`> \[!NOTE\]` → `> [!NOTE]`), and collapses trailing blank lines to a single - * newline. The table serializer's spurious surrounding blank lines are trimmed at the source - * (PipeSafeTable), so no global leading-newline strip is needed here — avoiding clobbering content - * that legitimately begins with whitespace. + * newline. Interior blank runs are NOT collapsed here — blank lines inside a fenced code block (or a + * verbatim raw-markdown-snippet) are significant, and a global collapse would corrupt them. Spurious + * interior blank runs between top-level blocks are removed upstream instead, by + * {@link parseMarkdownToDoc} stripping empty paragraphs, so a doc that has been through the editor + * never serializes with an interior blank run outside code in the first place. The table serializer's + * spurious surrounding blank lines are trimmed at the source (PipeSafeTable), so no global + * leading-newline strip is needed here — avoiding clobbering content that legitimately begins with + * whitespace. */ export function postProcessSerializedMarkdown(markdown: string): string { return collapseAutolinkedUrls( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts index d59ef6afe6e..0067ef31f47 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts @@ -7,6 +7,10 @@ import { createMarkdownContentExtensions } from './extensions' import { parseMarkdownToDoc, serializeMarkdownBody, splitMarkdownBlocks } from './markdown-parse' import { isRoundTripSafe } from './round-trip-safety' +/** Mirror of the production `isEmptyParagraph` (not exported): the shape a blank line reconstructs to. */ +const isEmptyPara = (n: { type?: string; content?: unknown[] }): boolean => + n.type === 'paragraph' && !n.content?.length + let editor: Editor | null = null afterEach(() => { editor?.destroy() @@ -59,12 +63,6 @@ const CASES: Array<[string, string]> = [ '1. First\n - sub bullet\n - another\n 1. deep ordered\n 2. item\n2. Second', ], ['heading-separated sections', '# A\n\nalpha\n\n## B\n\nbeta\n\n## C\n\ngamma'], - // Blank-line spacing: `@tiptap/markdown` reconstructs empty paragraphs from runs of blank lines, so - // the chunker must reinsert them or a saved blank line vanishes on reload. See the dedicated - // "empty paragraphs" suite below for the exact whole-document-parser parity. - ['one empty paragraph between paragraphs', 'first\n\n\n\nsecond'], - ['two empty paragraphs between paragraphs', 'first\n\n\n\n\n\nsecond'], - ['empty paragraphs between headings and text', '# A\n\n\n\nalpha\n\n\n\n## B'], ] describe('parseMarkdownToDoc (chunked)', () => { @@ -93,63 +91,62 @@ describe('parseMarkdownToDoc (chunked)', () => { expect(splitMarkdownBlocks('\n\n \n')).toEqual([]) }) - // The chunker used to drop empty paragraphs (visual blank lines between blocks) that the whole-document - // parser preserves, so a saved blank line silently vanished on the next load. These assert the chunked - // parse reconstructs the SAME empty-paragraph structure the whole-document parser does — at document - // edges and between blocks, for one or many blank lines, and around lists. - describe('empty paragraphs (blank-line spacing) match the whole-document parser', () => { - /** Block-type shape of a doc, `∅` for an empty paragraph, normalized through the editor. */ - function shapeOf(md: string, parse: 'chunked' | 'whole'): string { - editor = new Editor({ extensions: createMarkdownContentExtensions() }) - if (parse === 'whole') editor.commands.setContent(md, { contentType: 'markdown' }) - else editor.commands.setContent(parseMarkdownToDoc(md), { contentType: 'json' }) - const shape = (editor.getJSON().content ?? []) - .map((n) => (n.type === 'paragraph' && !n.content?.length ? '∅' : n.type)) + // Asserts the collapse documented on `stripEmptyParagraphs` — at document edges, between blocks, for + // one or many blank lines, and around lists. (Blank runs are insignificant in markdown, so a collapsed + // file renders identically everywhere it's viewed; the pathological case is a run of thousands.) + describe('collapses blank-line runs to markdown-standard spacing', () => { + /** Block-type shape of a doc after `parseMarkdownToDoc`, `∅` for any surviving empty paragraph. */ + function shapeOf(md: string): string { + return (parseMarkdownToDoc(md).content ?? []) + .map((n) => (isEmptyPara(n) ? '∅' : n.type)) .join(',') - editor.destroy() - editor = null - return shape } it.each([ - ['one empty between paragraphs', 'a\n\n\n\nb'], - ['two empties between paragraphs', 'a\n\n\n\n\n\nb'], - ['three empties between paragraphs', 'a\n\n\n\n\n\n\n\nb'], - ['even blank-line gap (rounds down)', 'a\n\n\n\n\nb'], - ['leading empties', '\n\n\n\na'], - ['leading + between', '\n\n\na\n\n\n\nb'], - ['empties between a heading and text', '# H\n\n\n\ntext'], - ['empties after a tight list', '- a\n- b\n\n\n\ntext'], - ['empties before a tight list', 'text\n\n\n\n- a\n- b'], - // Line-ending variants: the whole-vs-chunked routing must normalize first, or a `\r`-only body - // skips the empty-paragraph guard and is chunked (dropping the empties this fix restores). - ['CRLF between empties', 'a\r\n\r\n\r\n\r\nb'], - ['CR-only (classic Mac) between empties', 'a\r\r\r\rb'], - ])('chunked matches whole-doc: %s', (_label, md) => { - expect(shapeOf(md, 'chunked')).toBe(shapeOf(md, 'whole')) + ['one blank gap between paragraphs', 'a\n\n\n\nb', 'paragraph,paragraph'], + ['many blank lines between paragraphs', 'a\n\n\n\n\n\n\n\nb', 'paragraph,paragraph'], + ['leading blank lines', '\n\n\n\na', 'paragraph'], + ['leading + interior', '\n\n\na\n\n\n\nb', 'paragraph,paragraph'], + ['blank gap between a heading and text', '# H\n\n\n\ntext', 'heading,paragraph'], + ['blank gap after a tight list', '- a\n- b\n\n\n\ntext', 'bulletList,paragraph'], + ['blank gap before a tight list', 'text\n\n\n\n- a\n- b', 'paragraph,bulletList'], + // Line-ending variants normalize first, so `\r`-only / CRLF blank runs collapse identically. + ['CRLF between blocks', 'a\r\n\r\n\r\n\r\nb', 'paragraph,paragraph'], + ['CR-only (classic Mac) between blocks', 'a\r\r\r\rb', 'paragraph,paragraph'], + ])('collapses to no empty paragraphs: %s', (_label, md, expected) => { + expect(shapeOf(md)).toBe(expected) + }) + + it('a pathological blank run does not explode into empty paragraph nodes', () => { + // The production incident: an agent/paste artifact with a huge blank run became ~1959 empty + // paragraphs baked into the doc. Collapsing on parse neutralizes any such source. + const body = `Para A${'\n'.repeat(4000)}Para B` + const content = parseMarkdownToDoc(body).content ?? [] + expect(content.filter(isEmptyPara).length).toBe(0) + expect(content.map((n) => n.type)).toEqual(['paragraph', 'paragraph']) }) }) - // Regression: a file ending in a blank line (a trailing empty paragraph) must stay EDITABLE. Such an - // empty paragraph can't be serialized stably (postProcess collapses trailing newlines), so the parser - // strips it — keeping the doc round-trip-safe/idempotent instead of flipping the file read-only. - describe('trailing blank lines stay editable (regression)', () => { + // Regression: a file with blank lines (leading, interior, or trailing) must stay EDITABLE. Collapsing + // blank runs keeps serialize→parse idempotent, so the round-trip-safety probe reaches a fixed point + // instead of flipping the file read-only. + describe('blank lines stay editable (regression)', () => { it.each([ ['plain paragraph', 'abc\n\n'], ['heading + text', '# Title\n\nSome text\n\n'], ['three trailing newlines', 'hello\n\n\n'], ['two paragraphs', 'para one\n\npara two\n\n'], - ['interior empties + trailing', 'a\n\n\n\nb\n\n'], - ])('a file ending in a blank line is round-trip-safe: %s', (_label, md) => { + ['interior blank run + trailing', 'a\n\n\n\nb\n\n'], + ])('a file with blank lines is round-trip-safe: %s', (_label, md) => { expect(isRoundTripSafe(md)).toBe(true) }) - it('strips the trailing empty paragraph but keeps interior ones', () => { + it('removes only structurally-empty paragraphs — a paragraph with content survives', () => { + // The shape suite above already proves leading/interior/trailing blank runs collapse to zero empty + // paragraphs; this pins the complementary guarantee — a real (non-empty) paragraph is never dropped. const trailing = parseMarkdownToDoc('abc\n\n').content ?? [] expect(trailing.at(-1)?.type).toBe('paragraph') - expect(trailing.at(-1)?.content?.length ?? 0).toBeGreaterThan(0) - const interior = parseMarkdownToDoc('a\n\n\n\nb').content ?? [] - expect(interior.some((n) => n.type === 'paragraph' && !n.content?.length)).toBe(true) + expect(isEmptyPara(trailing.at(-1) ?? {})).toBe(false) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts index d8255eef7e6..6cdeeabf4ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts @@ -47,20 +47,6 @@ const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/ const LIST_MARKER = /^[ ]{0,3}(?:[-*+]|\d+[.)])\s/ const BLOCKQUOTE = /^[ ]{0,3}>/ -/** - * Blank-line spacing that `@tiptap/markdown` reconstructs as *interior* or *leading* empty paragraphs — - * a run of two or more blank lines somewhere, or blank line(s) at the document's leading edge. `[^\S\n]` - * matches horizontal whitespace, so a "blank" line may carry spaces/tabs. This is only ever tested - * against the `\r`-normalized body ({@link parseMarkdownToDoc}), so no CRLF handling is needed here. - * - * A *single* trailing blank line is deliberately not matched — purely to avoid routing an otherwise-plain - * file to the slower whole-document parser. Correctness does not depend on it: {@link parseMarkdownToDoc} - * strips trailing empty paragraphs on *both* parse paths ({@link stripTrailingEmptyParagraphs}), so - * serialize→parse stays idempotent regardless of which parser ran. (A trailing run of two or more blanks - * still matches the interior alternative — harmless, since the strip cleans it either way.) - */ -const EMPTY_PARAGRAPH_SPACING = /\n[^\S\n]*\n[^\S\n]*\n|^[^\S\n]*\n[^\S\n]*\n/ - /** * Split a markdown body into top-level blocks that can each be parsed independently and reassembled * without changing meaning. Blank lines separate candidate groups (fenced code blocks stay atomic), @@ -135,21 +121,20 @@ export function splitMarkdownBlocks(body: string): string[] { * Documents whose constructs span blocks ({@link NON_CHUNKABLE}) parse whole, and any failure falls * back to a single whole-document parse, so correctness never depends on the splitter. * - * Blank-line spacing ({@link EMPTY_PARAGRAPH_SPACING}) also parses whole: the chunker parses each block - * stripped of the blank lines between them, so it drops the empty paragraphs `@tiptap/markdown` builds - * from runs of blank lines — a saved visual blank line would silently vanish on reload. Whether a gap - * yields an empty paragraph is a global, block-type-dependent decision (kept between two paragraphs, - * dropped after a heading), so it can't be reconstructed block-locally; these documents parse whole for - * exact fidelity. Ordinary single-blank-line separation still takes the fast chunked path. + * Runs of blank lines take the fast chunked path too: the chunker parses each block stripped of the + * blank lines between them, which drops the empty paragraphs `@tiptap/markdown` reconstructs from a + * blank run — exactly what {@link stripEmptyParagraphs} does to the whole-parse output anyway. A blank + * run between blocks is insignificant in markdown, so collapsing it is the intended normalization (see + * {@link stripEmptyParagraphs}), and both parse paths converge on the same empty-paragraph-free result. */ export function parseMarkdownToDoc(body: string): JSONContent { const manager = markdownManager() - // Normalize line endings up front so the routing guards see the same `\n` the chunker and parser - // do — the guards' `\n`-anchored tests would otherwise miss a classic `\r`-only body (its blank - // lines are `\r`), routing it to the chunker that then drops its empty paragraphs. + // Normalize line endings up front so {@link NON_CHUNKABLE}'s `\n`-anchored tests see the same `\n` + // the chunker and parser do — a classic `\r`-only body would otherwise slip past the reference-def / + // block-HTML guard and be chunked, shattering a construct that must parse whole. const normalized = body.replace(/\r\n?/g, '\n') let doc: JSONContent - if (NON_CHUNKABLE.test(normalized) || EMPTY_PARAGRAPH_SPACING.test(normalized)) { + if (NON_CHUNKABLE.test(normalized)) { doc = manager.parse(normalized) } else { try { @@ -163,7 +148,7 @@ export function parseMarkdownToDoc(body: string): JSONContent { doc = manager.parse(normalized) } } - return stripTrailingEmptyParagraphs(doc) + return stripEmptyParagraphs(doc) } /** An empty paragraph node — the shape a blank line reconstructs to (no content, or `content: []`). */ @@ -172,19 +157,26 @@ function isEmptyParagraph(node: JSONContent): boolean { } /** - * Drop trailing empty paragraphs from a parsed doc. {@link postProcessSerializedMarkdown} collapses - * trailing blank lines to a single newline, so a trailing empty paragraph can never round-trip — the - * whole-document parser reconstructs one from a file ending in a blank line, but keeping it makes - * serialize→parse non-idempotent, which flips the file read-only via the round-trip-safety probe. - * Leading/interior empty paragraphs are untouched (postProcess never strips those). TipTap re-adds its - * own trailing filler paragraph on `setContent`, so the editor still has a place to type. + * Drop ALL top-level empty paragraphs from a parsed doc — leading, interior, and trailing. In markdown + * a run of blank lines between blocks is insignificant (CommonMark collapses it), so `@tiptap/markdown` + * reconstructing each blank as an empty paragraph node is not fidelity: it makes the editor render the + * file differently from every standard renderer (GitHub, the download, our own static preview), and a + * pathological blank run (an agent/paste artifact) explodes into thousands of empty nodes that persist + * forever and reflow the doc on open. Collapsing them here keeps normal single-blank-line block spacing + * while removing the spurious gaps, and stays idempotent so the round-trip-safety probe still passes: a + * doc parsed this way has no empty paragraphs, so re-serializing it never re-emits an interior blank run + * (the serializer is intentionally left alone — a blank line inside a fenced code block IS significant), + * and a second parse is a fixed point. Only TOP-LEVEL paragraphs are touched, so blank lines that carry + * meaning inside a construct (e.g. a loose list) are left to the block parser. TipTap re-adds its own + * trailing filler paragraph on `setContent`, so the editor still has a place to type. */ -function stripTrailingEmptyParagraphs(doc: JSONContent): JSONContent { +function stripEmptyParagraphs(doc: JSONContent): JSONContent { const content = doc.content if (!content || content.length === 0) return doc - let end = content.length - while (end > 0 && isEmptyParagraph(content[end - 1])) end-- - return end === content.length ? doc : { ...doc, content: content.slice(0, end) } + // The dominant (chunked) parse already emits no top-level empty paragraphs, so scan before allocating: + // return the doc untouched — no array copy — unless there is actually something to strip. + if (!content.some(isEmptyParagraph)) return doc + return { ...doc, content: content.filter((node) => !isEmptyParagraph(node)) } } /** diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 84c480a4c47..97899c73e10 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1634,6 +1634,7 @@ export function Files() { onSelect: handleShareSelected, }, { + id: 'delete', text: 'Delete', icon: Trash, onSelect: handleDeleteSelected, diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 929a673c993..b18dc4cafa9 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -2,11 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Chip, ChipDropdown, ChipLink, cn } from '@sim/emcn' -import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' -import Link from 'next/link' +import { ArrowLeft, Plus } from 'lucide-react' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' -import { PAGE_HEADER_BAR } from '@/components/page-header-bar' +import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' import { isChatEnabled } from '@/lib/core/config/env-flags' import { blockTypeToIconMap, @@ -16,6 +15,7 @@ import { } from '@/lib/integrations' import { credentialProviderMatchesService } from '@/lib/oauth' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' +import { RESOURCE_TILE_BASE } from '@/app/workspace/[workspaceId]/components/resource-tile' import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section' import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params' import { @@ -26,6 +26,11 @@ import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/c import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route' import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { getTileIconColorClass } from '@/blocks/icon-color' import { storeCuratedPrompt } from '@/blocks/integration-matcher' import { @@ -141,7 +146,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration Integrations -
+
{oauthService ? ( hasServiceAccount ? ( ) : (
{integration.name.charAt(0)} @@ -216,22 +218,18 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration {connectedCredentials.length > 0 && ( {connectedCredentials.map((credential) => ( - } + title={credential.displayName} + description={ + credential.description || resolveCredentialDisplay(credential).subtitle + } href={`/workspace/${workspaceId}/integrations/connected/${credential.id}`} - className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' - > - {Icon && } -
- - {credential.displayName} - - - {credential.description || resolveCredentialDisplay(credential).subtitle} - -
- - + clickLabel={`Open ${credential.displayName}`} + navigable + /> ))}
)} @@ -274,10 +272,8 @@ function TemplatesSection({ integration, templates, workspaceId }: TemplatesSect } return ( -
- Templates -
-
+ +
{templates.map((template) => { const blockTypes = [integration.type, ...template.otherBlockTypes].slice( 0, @@ -294,7 +290,7 @@ function TemplatesSection({ integration, templates, workspaceId }: TemplatesSect ) })}
-
+ ) } @@ -306,25 +302,21 @@ interface TemplateRowProps { } /** - * Template row that mirrors `IntegrationItem` from the integrations index - * byte-for-byte (icon cluster · title · description · trailing `ArrowRight`). - * Renders as a ` + clickLabel={`Use template ${title}`} + navigable + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx index ea4fa847a2b..575a448fe2f 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx @@ -6,6 +6,11 @@ import { Check, Plus } from 'lucide-react' import { usePostHog } from 'posthog-js/react' import { captureEvent } from '@/lib/posthog/client' import { SkillTile } from '@/app/workspace/[workspaceId]/components' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { isSkillNameConflictError } from '@/app/workspace/[workspaceId]/skills/components/utils' import type { SuggestedSkill } from '@/blocks/types' import { useCreateSkill, useSkills } from '@/hooks/queries/skills' @@ -26,22 +31,23 @@ interface SkillRowProps { function SkillRow({ skill, added, pending, disabled, onAdd }: SkillRowProps) { return ( -
- -
- {skill.name} - {skill.description} -
- {added ? ( - - Added - - ) : ( - - {pending ? 'Adding...' : 'Add'} - - )} -
+ } + title={skill.name} + description={skill.description} + trailing={ + added ? ( + + Added + + ) : ( + + {pending ? 'Adding...' : 'Add'} + + ) + } + /> ) } @@ -98,10 +104,8 @@ export function IntegrationSkillsSection({ } return ( -
- Skills -
-
+ +
{skills.map((skill, index) => ( ))}
-
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx index 827cbe519c7..08dae05a2cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx @@ -1,4 +1,6 @@ import type { ReactNode } from 'react' +import { RESOURCE_LIST_GRID } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' interface IntegrationSectionProps { label: string @@ -6,19 +8,15 @@ interface IntegrationSectionProps { } /** - * Labeled section used throughout the integrations surface. Renders a small - * caption, a divider, and a responsive auto-fit grid for its children so the - * vertical rhythm stays consistent across the integrations list, the connected - * credentials list, and the integration detail page templates. + * Labeled section used throughout the integrations surface: the shared + * {@link SettingsSection} label/divider chrome wrapped around the shared + * responsive card grid, so the integrations list, the connected credentials + * list, and the integration detail templates cannot drift from settings. */ export function IntegrationSection({ label, children }: IntegrationSectionProps) { return ( -
- {label} -
-
- {children} -
-
+ +
{children}
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx index b4d71e20bab..3706f107890 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' -import { ChipLink } from '@sim/emcn' -import { PAGE_HEADER_BAR } from '@/components/page-header-bar' +import { ChipLink, cn } from '@sim/emcn' +import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' interface IntegrationTabsHeaderProps { active: 'integrations' | 'skills' @@ -26,7 +26,7 @@ export function IntegrationTabsHeader({ Skills - {rightSlot &&
{rightSlot}
} + {rightSlot &&
{rightSlot}
}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx index e3b01070117..43a25c08ba1 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx @@ -1,5 +1,9 @@ import type { ComponentType } from 'react' import { cn } from '@sim/emcn' +import { + RESOURCE_TILE_BASE, + RESOURCE_TILE_PLAIN, +} from '@/app/workspace/[workspaceId]/components/resource-tile' import { getBlock } from '@/blocks' import { getTileIconColorClass } from '@/blocks/icon-color' @@ -69,13 +73,11 @@ export function IntegrationTile({ blockType, icon: Icon, framed = false }: Integ if (!framed) { return ( -
-
- -
+
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx index 8009841fe31..f5363986d1f 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx @@ -1,7 +1,7 @@ 'use client' import { Chip } from '@sim/emcn' -import { ArrowRight } from 'lucide-react' +import { ArrowRight } from '@sim/emcn/icons' import { useParams, useRouter } from 'next/navigation' import { isChatEnabled } from '@/lib/core/config/env-flags' import { IntegrationsShowcase } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx index fa40014816c..45ffddc13bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx @@ -8,6 +8,7 @@ import { ChipInput, ChipLink, ChipTextarea, + cn, Send, toast, } from '@sim/emcn' @@ -27,11 +28,16 @@ import { UnsavedChangesModal, useCredentialDetailForm, } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { + RESOURCE_TILE_BASE, + RESOURCE_TILE_PLAIN, +} from '@/app/workspace/[workspaceId]/components/resource-tile' import { ConnectServiceAccountModal, type ServiceAccountProviderId, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { useCreateCredentialDraft, useDeleteWorkspaceCredential, @@ -217,7 +223,7 @@ export function ConnectedCredentialDetail({ if (credentialsLoading && !credential) { return ( -

Loading…

+ Loading…
) } @@ -225,7 +231,7 @@ export function ConnectedCredentialDetail({ if (!credential) { return ( -

Credential not found.

+ Credential not found.
) } @@ -241,7 +247,7 @@ export function ConnectedCredentialDetail({ display?.icon ? ( ) : ( -
+
{resolveProviderLabel(credential.providerId).slice(0, 1) || '?'} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index 2f04ac50d33..c9a8aa539f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -2,7 +2,6 @@ import { type ComponentType, useCallback, useMemo, useRef } from 'react' import { - ArrowRight, ChevronDown, ChipInput, chipVariants, @@ -12,7 +11,6 @@ import { DropdownMenuTrigger, Search, } from '@sim/emcn' -import Link from 'next/link' import { useParams } from 'next/navigation' import { useQueryStates } from 'nuqs' import { @@ -34,18 +32,14 @@ import { integrationsParsers, integrationsUrlKeys, } from '@/app/workspace/[workspaceId]/integrations/search-params' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' /** Slugs surfaced in the pinned Featured section, in display order. */ const FEATURED_SLUGS = ['slack', 'gmail', 'jira', 'github', 'google-sheets', 'hubspot'] as const -const LINK_ROW_CLASSES = - 'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' -const LINK_ROW_TITLE_CLASSES = 'truncate text-[14px] text-[var(--text-body)]' -const LINK_ROW_SUBTITLE_CLASSES = 'truncate text-[12px] text-[var(--text-muted)]' -const LINK_ROW_ARROW_CLASSES = 'size-4 flex-shrink-0 text-[var(--text-icon)]' - const FEATURED_INTEGRATIONS: readonly Integration[] = (() => { const bySlug = new Map(INTEGRATIONS.map((i) => [i.slug, i])) return FEATURED_SLUGS.map((slug) => bySlug.get(slug)).filter( @@ -85,14 +79,15 @@ function IntegrationItem({ icon: Icon, }: IntegrationItemProps) { return ( - - -
- {name} - {description && {description}} -
- - + } + title={name} + description={description || undefined} + href={`/workspace/${workspaceId}/integrations/${slug}`} + clickLabel={`Open ${name}`} + navigable + /> ) } @@ -122,14 +117,15 @@ interface ConnectedItemProps { function ConnectedItem({ href, blockType, name, description, icon: Icon }: ConnectedItemProps) { return ( - - -
- {name} - {description} -
- - + } + title={name} + description={description} + href={href} + clickLabel={`Open ${name}`} + navigable + /> ) } @@ -361,11 +357,11 @@ export function Integrations() { ))} {showNoResults && ( -
+ {urlSearchTerm.trim() ? `No integrations found matching “${urlSearchTerm}”` : 'No integrations in this category'} -
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx index f7bf16ba921..b9afc65ea72 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx @@ -16,6 +16,7 @@ interface TaskContextMenuProps { onClose: () => void /** The right-clicked task; its status decides which actions render. */ task: ScheduledTask | null + canEdit: boolean onEdit: () => void /** Opens a new-task modal pre-filled from this task. */ onDuplicate: () => void @@ -37,6 +38,7 @@ export function TaskContextMenu({ position, onClose, task, + canEdit, onEdit, onDuplicate, onPause, @@ -72,10 +74,12 @@ export function TaskContextMenu({ > {isUpcoming ? ( <> - - - Edit - + {canEdit && ( + + + Edit + + )} {canPauseResume && (task?.disabled ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx index 2666b89186e..b52cb043e54 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx @@ -39,8 +39,8 @@ interface TaskDetailsModalProps { } /** - * Read-only record modal for tasks that are running or already finished — - * pending tasks open the edit `TaskModal` instead. Three plaintext fields: + * Read-only record modal for tasks that are running, finished, or owned by + * another execution actor. Three plaintext fields: * Status and the run time as copy fields, the prompt as a view-only chip editor. */ export function TaskDetailsModal({ task, onClose }: TaskDetailsModalProps) { diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section.tsx new file mode 100644 index 00000000000..a4b14c8adb7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section.tsx @@ -0,0 +1,67 @@ +'use client' + +import { ChipModalField, ChipModalSeparator, ChipSelect } from '@sim/emcn' +import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' +import { useRawMountableSecretOptions } from '@/hooks/queries/secret-mount-options' + +const SECRET_SCOPE_OPTIONS = [ + { value: 'all', label: 'All secrets' }, + { value: 'selected', label: 'Selected secrets' }, +] + +interface SecretAccessSectionProps extends SecretMountPolicy { + workspaceId: string + onChange: (policy: SecretMountPolicy) => void +} + +export function SecretAccessSection({ + workspaceId, + secretScope, + mountedSecrets, + onChange, +}: SecretAccessSectionProps) { + const { options, isPending } = useRawMountableSecretOptions(workspaceId) + + return ( +
+ +
+ + + onChange({ + secretScope: value === 'selected' ? 'selected' : 'all', + mountedSecrets, + }) + } + options={SECRET_SCOPE_OPTIONS} + /> + + + {secretScope === 'selected' && ( + + + onChange({ secretScope: 'selected', mountedSecrets: values }) + } + disabled={isPending} + /> + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx index de88be597bf..fad48a1bd31 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx @@ -13,12 +13,17 @@ import { import { Calendar } from '@sim/emcn/icons' import { format } from 'date-fns' import { useParams } from 'next/navigation' +import { + DEFAULT_SECRET_MOUNT_POLICY, + type SecretMountPolicy, +} from '@/lib/copilot/secret-mount-policy' import { wallClockNow, zonedWallClockToUtc } from '@/lib/core/utils/timezone' import { PromptEditor, usePromptEditor, } from '@/app/workspace/[workspaceId]/home/components/user-input/components' import { RecurrenceSection } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section' +import { SecretAccessSection } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section' import type { CalendarSlot } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar' import { DEFAULT_RECURRENCE, @@ -69,7 +74,7 @@ function defaultLaunch( } /** The data a task create or edit captures. */ -export interface TaskDraft { +export interface TaskDraft extends SecretMountPolicy { prompt: string /** Resources the prompt `@`-mentions / skills it `/`-invokes, when any. */ contexts?: ChatContext[] @@ -80,7 +85,7 @@ export interface TaskDraft { } /** Pre-filled fields shared by the edit and duplicate flows. */ -export interface TaskPrefill { +export interface TaskPrefill extends SecretMountPolicy { prompt: string /** Stored `@`-mention contexts, re-registered so they carry over. */ contexts?: ChatContext[] @@ -223,6 +228,10 @@ function TaskModalContent({ const [recurrence, setRecurrence] = useState( () => source?.recurrence ?? DEFAULT_RECURRENCE ) + const [secretPolicy, setSecretPolicy] = useState(() => ({ + secretScope: source?.secretScope ?? DEFAULT_SECRET_MOUNT_POLICY.secretScope, + mountedSecrets: source?.mountedSecrets ?? DEFAULT_SECRET_MOUNT_POLICY.mountedSecrets, + })) const launchEditedRef = useRef(false) /** * Synchronous mirror of `submitting` that gates {@link handleSubmit}. The @@ -286,6 +295,7 @@ function TaskModalContent({ launchTime, timezone, recurrence, + ...secretPolicy, }) ) .then(() => true) @@ -331,6 +341,7 @@ function TaskModalContent({ /> + maxRuns: fields.maxRuns ?? null, endsAt: fields.endsAt ?? null, contexts: draft.contexts ?? [], + secretScope: draft.secretScope, + mountedSecrets: draft.mountedSecrets, } } @@ -212,6 +216,8 @@ export function useScheduledTasks({ launchTime, timezone: schedule.timezone, recurrence, + secretScope: schedule.secretScope, + mountedSecrets: schedule.mountedSecrets, } }, [schedules] diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx index 8f3410fe014..4d8f1c86cd1 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react' import { Calendar, Plus } from '@sim/emcn/icons' import { useParams } from 'next/navigation' +import { useSession } from '@/lib/auth/auth-client' import type { ResourceAction } from '@/app/workspace/[workspaceId]/components' import { Resource } from '@/app/workspace/[workspaceId]/components' import { ScheduleCalendar } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar' @@ -23,6 +24,7 @@ import { useTimezone } from '@/hooks/queries/general-settings' export function ScheduledTasks() { const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: session } = useSession() const timezone = useTimezone() const calendar = useCalendar(timezone) @@ -32,9 +34,12 @@ export function ScheduledTasks() { ) const tasks = useScheduledTasks({ workspaceId, rangeStart: range.start, rangeEnd: range.end }) - /** Pending tasks open the editable TaskModal; running/finished open the record. */ - const editTask = tasks.selectedTask?.status === 'pending' ? tasks.selectedTask : null - const recordTask = tasks.selectedTask?.status !== 'pending' ? tasks.selectedTask : null + /** Only the execution actor may edit task contents; every other view is read-only. */ + const selectedTaskIsEditable = + tasks.selectedTask?.status === 'pending' && + tasks.selectedTask.sourceUserId === session?.user?.id + const editTask = selectedTaskIsEditable ? tasks.selectedTask : null + const recordTask = tasks.selectedTask && !selectedTaskIsEditable ? tasks.selectedTask : null const editSeed = editTask ? tasks.editSeedFor(editTask) : null const { @@ -183,6 +188,7 @@ export function ScheduledTasks() { position={taskContextMenuPosition} onClose={closeTaskContextMenu} task={contextTask} + canEdit={contextTask?.sourceUserId === session?.user?.id} onEdit={openContextTask} onDuplicate={handleDuplicate} onPause={handlePauseContextTask} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts index 313af910c11..e2c08c48b30 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts @@ -15,6 +15,7 @@ function makeTask(overrides: Partial): ScheduledTask { return { id: 't1', scheduleId: 's1', + sourceUserId: 'user-1', prompt: 'Summarize yesterday', runAt: new Date('2026-06-10T14:30:00.000Z'), timezone: 'UTC', @@ -93,13 +94,18 @@ describe('taskToCalendarEvent', () => { describe('scheduleToTasks', () => { it('renders an active one-time task as a single pending occurrence at its next run', () => { const tasks = scheduleToTasks( - makeRow({ nextRunAt: '2026-06-11T09:00:00.000Z' }), + makeRow({ nextRunAt: '2026-06-11T09:00:00.000Z', sourceUserId: 'creator-1' }), RANGE_START, RANGE_END, NOW ) expect(tasks).toHaveLength(1) - expect(tasks[0]).toMatchObject({ scheduleId: 's1', status: 'pending', recurring: false }) + expect(tasks[0]).toMatchObject({ + scheduleId: 's1', + sourceUserId: 'creator-1', + status: 'pending', + recurring: false, + }) expect(tasks[0].runAt.toISOString()).toBe('2026-06-11T09:00:00.000Z') }) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts index 141a5d7c32c..0361e6325b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts @@ -20,6 +20,8 @@ export interface ScheduledTask { id: string /** The persisted schedule id, used to edit or delete the task. */ scheduleId: string + /** The user whose authority executes the task and who may edit its contents. */ + sourceUserId: string | null /** The instruction Sim runs. Doubles as the calendar title. */ prompt: string /** Resources the prompt `@`-mentions / skills it `/`-invokes, when any. */ @@ -100,6 +102,7 @@ export function scheduleToTasks( const contexts = (row.contexts ?? undefined) as unknown as ChatContext[] | undefined const base = { scheduleId: row.id, + sourceUserId: row.sourceUserId, prompt: row.prompt ?? '', contexts, timezone: row.timezone, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index b9a95f6564c..9409ccc55e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -168,6 +168,7 @@ export function SettingsPage({ section }: SettingsPageProps) { )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx index 2c9f36ab1db..18402ab42ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx @@ -151,7 +151,7 @@ export function CreditUsageView({ backHref = '/account/settings/billing' }: Cred return ( router.push(backHref), }} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx index 934c025f57f..6071a86e11a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx @@ -17,7 +17,7 @@ export function CreditUsageLoading({ backHref }: CreditUsageLoadingProps) { return ( router.push(backHref), }} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index 5a2daaa70ca..e456d250d1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -302,8 +302,8 @@ export function Admin() { <>
- -

+ +

Default uses the configured Sim agent URL.

diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index 2c7b1561383..6daf1e083cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -13,6 +13,10 @@ import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/component import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { @@ -198,24 +202,50 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { {showsWorkspaceKeys && !searchTerm.trim() ? ( {workspaceKeys.length === 0 ? ( -
No workspace API keys yet
+ + No workspace API keys yet + ) : ( -
+
{workspaceKeys.map((key) => ( -
-
-
- - {key.name} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

- {key.displayKey} -

-
+ + {`last used ${formatLastUsed(key.lastUsed).toLowerCase()}`} + + } + trailing={ + { + setDeleteKey(key) + setShowDeleteDialog(true) + }} + canDelete={canManageWorkspaceKeys} + /> + } + /> + ))} +
+ )} + + ) : showsWorkspaceKeys && filteredWorkspaceKeys.length > 0 ? ( + +
+ {filteredWorkspaceKeys.map(({ key }) => ( + + {`last used ${formatLastUsed(key.lastUsed).toLowerCase()}`} + + } + trailing={ { @@ -224,38 +254,8 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { }} canDelete={canManageWorkspaceKeys} /> -
- ))} -
- )} - - ) : showsWorkspaceKeys && filteredWorkspaceKeys.length > 0 ? ( - -
- {filteredWorkspaceKeys.map(({ key }) => ( -
-
-
- - {key.name} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

- {key.displayKey} -

-
- { - setDeleteKey(key) - setShowDeleteDialog(true) - }} - canDelete={canManageWorkspaceKeys} - /> -
+ } + /> ))}
@@ -263,38 +263,34 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { {showsPersonalKeys && (!searchTerm.trim() || filteredPersonalKeys.length > 0) && ( -
+
{filteredPersonalKeys.map(({ key }) => { const isConflict = conflictNames.has(key.name) return ( -
-
-
-
- - {key.name} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

- {key.displayKey} -

-
- { - setDeleteKey(key) - setShowDeleteDialog(true) - }} - /> -
+
+ + {`last used ${formatLastUsed(key.lastUsed).toLowerCase()}`} + + } + trailing={ + { + setDeleteKey(key) + setShowDeleteDialog(true) + }} + /> + } + /> {isConflict && ( -
+

Workspace API key with the same name overrides this. Rename your personal key to use it. -

+

)}
) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx index 05da1f82dbe..a00cc5a639d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx @@ -161,7 +161,12 @@ vi.mock( ) vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ - SettingsPanel: ({ children }: { children: ReactNode }) =>
{children}
, + SettingsPanel: ({ children, description }: { children: ReactNode; description?: string }) => ( +
+ {description &&

{description}

} + {children} +
+ ), })) vi.mock( @@ -259,7 +264,13 @@ describe('Billing payer scope', () => { it('uses the target organization DTO for annual, canceled, credit, cap, and link state', async () => { await act(async () => { - root.render() + root.render( + + ) }) expect(mockUseSubscriptionData).toHaveBeenCalledWith( @@ -270,6 +281,9 @@ describe('Billing payer scope', () => { container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent ).toBe('Explore organization plans') expect(container.textContent).toContain('Organization Max for Teams plan') + expect(container.textContent).toContain( + 'Target organization’s subscription governs Production.' + ) expect(container.textContent).toContain('billed annually') expect(container.textContent).toContain('Access until') expect(container.textContent).toContain('Subscription canceled') @@ -293,13 +307,35 @@ describe('Billing payer scope', () => { it('uses a guaranteed personal payer workspace for account upgrades', async () => { await act(async () => { - root.render() + root.render() }) expect( container.querySelector('a[href="/workspace/personal-workspace/upgrade"]')?.textContent ).toBe('Explore personal plans') expect(container.textContent).toContain('Personal Pro plan') + expect(container.textContent).toContain( + 'Your personal subscription governs Personal workspace.' + ) + }) + + it('does not show a governing subscription description for a free personal workspace', async () => { + mockPersonalQuery.current = { + data: { + success: true, + context: 'user', + data: { ...PERSONAL_DATA, plan: 'free', status: 'active' }, + }, + isLoading: false, + refetch: vi.fn(), + } + + await act(async () => { + root.render() + }) + + expect(container.textContent).toContain('Personal Free plan') + expect(container.querySelector('main > p')).toBeNull() }) it('renders an explicit free organization state without subscription controls', async () => { @@ -319,12 +355,19 @@ describe('Billing payer scope', () => { } await act(async () => { - root.render() + root.render( + + ) }) expect(container.textContent).toContain('Organization Free plan') expect(container.textContent).toContain('No active organization subscription') expect(container.textContent).not.toContain('Payment method') + expect(container.querySelector('main > p')).toBeNull() }) it('renders lapsed organization plans as ended rather than active', async () => { @@ -342,12 +385,19 @@ describe('Billing payer scope', () => { } await act(async () => { - root.render() + root.render( + + ) }) expect(container.textContent).toContain('Organization Max for Teams plan ended') expect(container.textContent).toContain('Choose a new plan for this organization') expect(container.textContent).not.toContain('Cancel subscription') + expect(container.querySelector('main > p')).toBeNull() expect( container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent ).toBe('Explore organization plans') diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 851bc668d9c..0505c9fb8f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -47,6 +47,7 @@ import { CreditUsageSection } from '@/app/workspace/[workspaceId]/settings/compo import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field' import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { RESOURCE_ROW_ARROW_CLASSES } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useBillingUsageNotifications, @@ -102,9 +103,15 @@ interface BillingProps { scope: 'account' | 'organization' organizationId?: string creditUsageHref?: string + governingWorkspaceName?: string } -export function Billing({ scope, organizationId, creditUsageHref }: BillingProps) { +export function Billing({ + scope, + organizationId, + creditUsageHref, + governingWorkspaceName, +}: BillingProps) { const router = useRouter() const isOrganizationScope = scope === 'organization' @@ -447,9 +454,16 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps const explorePlansLabel = isOrganizationScope ? 'Explore organization plans' : 'Explore personal plans' + const subscriptionOwner = isOrganizationScope + ? `${organizationBilling?.organizationName ?? 'The organization'}’s subscription` + : 'Your personal subscription' + const settingsDescription = + governingWorkspaceName && subscription.isPaid + ? `${subscriptionOwner} governs ${governingWorkspaceName}.` + : undefined return ( - +
@@ -624,7 +638,7 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps {invoice.description ?? ''} - + ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx index e154e12b593..5d1d78265e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx @@ -14,6 +14,8 @@ const { mockBridge, mockToast } = vi.hoisted(() => ({ })) vi.mock('@sim/emcn', () => ({ + /** `password-detail` composes the shared tile classes with `cn`. */ + cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), ArrowLeft: () => , Button: ({ children, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx index 0c301136264..7a805605904 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx @@ -8,6 +8,7 @@ import { ChipConfirmModal, ChipCopyInput, ChipInput, + cn, Duplicate, Eye, EyeOff, @@ -16,6 +17,11 @@ import { toast, } from '@sim/emcn' import { getDesktopBridge } from '@/lib/desktop' +import { + RESOURCE_TILE_BASE, + RESOURCE_TILE_PLAIN, +} from '@/app/workspace/[workspaceId]/components/resource-tile' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -127,8 +133,8 @@ export function PasswordDetail({ credential, onBack, onForgotten }: PasswordDeta description='Saved on this device, encrypted. Chat can never read, choose, or type it.' actions={[ { + id: 'delete', text: 'Forget', - variant: 'destructive' as const, onSelect: () => setConfirmingForget(true), disabled: busy, }, @@ -136,32 +142,27 @@ export function PasswordDetail({ credential, onBack, onForgotten }: PasswordDeta >
-
- Site +
-
-
- {credential.icon ? ( - // A `data:` URL copied from the source browser at import - // time — never a network request, which would disclose - // which sites the user has passwords for. - - ) : ( - - )} -
+
+ {credential.icon ? ( + // A `data:` URL copied from the source browser at import + // time — never a network request, which would disclose + // which sites the user has passwords for. + + ) : ( + + )}
-
+
-
- Username + -
+ -
- Password + } /> -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx index b38ba8b8149..b334e010c14 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx @@ -175,11 +175,14 @@ async function click(button: HTMLButtonElement) { }) } -/** Cards are buttons outside the header; each shows a site and a username. */ -const cards = () => - [...container.querySelectorAll('main > button, main div button')].filter( +/** Each card's hit area — a stretched overlay button owned by `SettingsResourceRow`. */ +const cardButtons = () => + [...container.querySelectorAll('main button[aria-label^="Open "]')].filter( (node) => !node.closest('header') - ) + ) as HTMLButtonElement[] + +/** The row wrapping each hit area; it carries the visible site and username. */ +const cards = () => cardButtons().map((button) => button.parentElement as HTMLElement) const bridge = () => mockBridge.current as ReturnType @@ -220,7 +223,7 @@ describe('PasswordsView', () => { it('opens the detail page for the card that was clicked', async () => { await render() - await click(cards()[1] as HTMLButtonElement) + await click(cardButtons()[1]) expect(container.querySelector('[aria-label="Password detail"]')?.textContent).toBe( 'https://fubo.tv' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx index aad358a1fba..afe1b8fe2e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx @@ -7,21 +7,18 @@ import type { BrowserImportError, BrowserImportProfile, } from '@sim/desktop-bridge' -import { ArrowLeft, ArrowRight, ChipConfirmModal, Key, Plus, toast } from '@sim/emcn' +import { ArrowLeft, ChipConfirmModal, Key, Plus, toast } from '@sim/emcn' import { getDesktopBridge } from '@/lib/desktop' import { ImportModal } from '@/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal' import { PasswordDetail } from '@/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_GRID, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' -/** The integrations page's responsive card grid (see `integration-section.tsx`, `skills.tsx`). */ -const CARD_GRID = '-mx-2 grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-x-2 gap-y-0.5' -/** Card hit area; the row chrome inside it comes from {@link SettingsResourceRow}. */ -const CARD_CLASSES = - 'w-full rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' - const IMPORT_ERROR_MESSAGES: Record = { 'unsupported-platform': 'Importing from another browser is only supported on macOS.', 'chrome-not-found': 'Could not find that browser profile.', @@ -193,32 +190,28 @@ export function PasswordsView({ credentials, onChange, onBack, onImported }: Pas ) : ( <> -
+
{filtered.map((credential) => ( - + clickLabel={`Open ${siteLabel(credential.origin)}`} + navigable + /> ))}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx index 51d3bd630a8..a9d10d9a10d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx @@ -5,13 +5,13 @@ import { Button, Chip, ChipConfirmModal, + ChipInput, ChipModal, ChipModalBody, ChipModalError, ChipModalField, ChipModalFooter, ChipModalHeader, - cn, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -21,9 +21,11 @@ import { CHIP_FIELD_SHELL, } from '@/app/workspace/[workspaceId]/components/credential-detail/components/chip-field' import { BYOKProviderKeysModal } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal' -import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' const logger = createLogger('BYOKKeyManager') @@ -308,31 +310,20 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { <>
{showSearch && ( -
- - setSearchTerm(e.target.value)} - disabled={isLoading} - className={cn(CHIP_FIELD_INPUT, 'disabled:cursor-not-allowed disabled:opacity-60')} - /> -
+ setSearchTerm(e.target.value)} + disabled={isLoading} + className='w-full' + /> )} {description &&

{description}

} - {isLoading ? ( -
- {providers.map((p) => ( - - ))} -
- ) : showNoResults ? ( + {isLoading ? null : showNoResults ? ( No providers found matching "{searchTerm}" @@ -346,13 +337,13 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { return ( -
{rows.map(renderRow)}
+
{rows.map(renderRow)}
) })}
) : ( -
{filteredProviders.map(renderRow)}
+
{filteredProviders.map(renderRow)}
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx deleted file mode 100644 index 71d220b9c37..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Skeleton } from '@sim/emcn' - -/** - * Skeleton component for BYOK provider key items. - */ -export function BYOKKeySkeleton() { - return ( -
-
- -
- - -
-
-
- - -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx index c746dd62828..b7cd5655408 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from 'react' import { - Chip, ChipConfirmModal, ChipModal, ChipModalBody, @@ -15,9 +14,14 @@ import { import { createLogger } from '@sim/logger' import { formatDate } from '@sim/utils/formatting' import { Plus } from 'lucide-react' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { type CopilotKey, @@ -132,30 +136,33 @@ export function Copilot() { {isLoading ? null : showEmptyState ? ( Click "Create API key" above to get started ) : ( -
+
{filteredKeys.map((key) => ( -
-
-
- - {key.name || 'Unnamed Key'} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

{key.displayKey}

-
- { - setDeleteKey(key) - setShowDeleteDialog(true) - }} - > - Delete - -
+ + {`last used ${formatLastUsed(key.lastUsed).toLowerCase()}`} + + } + trailing={ + { + setDeleteKey(key) + setShowDeleteDialog(true) + }, + }, + ]} + /> + } + /> ))} {showNoResults && ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx index 88788ec0ea7..809feeeb408 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx @@ -2,15 +2,11 @@ import { useMemo, useState } from 'react' import { ChipConfirmModal, toast } from '@sim/emcn' -import { ArrowLeft, Wrench } from '@sim/emcn/icons' +import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { saveDiscardActions } from '@/components/settings/save-discard-actions' -import { ResourceTile } from '@/app/workspace/[workspaceId]/components' -import { - CredentialDetailHeading, - UnsavedChangesModal, -} from '@/app/workspace/[workspaceId]/components/credential-detail' +import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { CUSTOM_TOOL_DELETE_CONFIRM_TEXT, CustomToolCodeField, @@ -204,6 +200,11 @@ export function CustomToolDetail({ guard.guardBack(onBack) }} title={identity.name || tool?.title || 'New tool'} + description={ + identity.description || + tool?.schema.function.description || + 'Define the JSON schema your agents call, and the code that runs.' + } actions={[ ...(readOnly ? [] @@ -218,8 +219,8 @@ export function CustomToolDetail({ ...(tool && !readOnly ? [ { + id: 'delete', text: deleteTool.isPending ? 'Deleting...' : 'Delete', - variant: 'destructive' as const, onSelect: () => setShowDeleteConfirm(true), disabled: deleteTool.isPending, }, @@ -228,16 +229,6 @@ export function CustomToolDetail({ ]} >
- } - title={identity.name || tool?.title || 'New tool'} - subtitle={ - identity.description || - tool?.schema.function.description || - 'Define the JSON schema your agents call, and the code that runs.' - } - /> - {error ? ( -
-

- {getErrorMessage(error, 'Failed to load tools')} -

-
+ + {getErrorMessage(error, 'Failed to load tools')} + ) : isLoading ? null : showEmptyState ? ( {canEdit ? 'Click "Add tool" above to get started' : 'No custom tools configured'} ) : ( -
+
{filteredTools.map((tool) => ( - + clickLabel={`Open ${tool.title || 'Unnamed Tool'}`} + navigable + /> ))} {showNoResults && ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx index d0c06152363..6fe4b1e29f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx @@ -15,7 +15,10 @@ import { getDesktopBridge, getDesktopShellVersion, getDesktopUpdates } from '@/l import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' function getMounts(response: LocalFilesystemResponse): LocalFilesystemMount[] | null { @@ -297,7 +300,7 @@ export function Desktop() { No folder access granted. Chat can only read folders you add here. ) : ( -
+
{mounts.map((mount) => ( void revealFolder(mount)} clickLabel={`Show ${mount.name} in the file manager`} + badge={ + !mount.remembered ? ( + + Until app restarts + + ) : undefined + } trailing={ -
- {!mount.remembered && ( - - Until app restarts - - )} - setMountToForget(mount), - }, - ]} - /> -
+ setMountToForget(mount), + }, + ]} + /> } /> ))} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx index d34e1036307..b9491208fe9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx @@ -11,6 +11,8 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + ChipSelect, + Label, Tooltip, useCopyToClipboard, } from '@sim/emcn' @@ -24,7 +26,16 @@ import { useInboxSenders, useRemoveInboxSender, useUpdateInboxAddress, + useUpdateInboxSecretPolicy, } from '@/hooks/queries/inbox' +import { useRawMountableSecretOptions } from '@/hooks/queries/secret-mount-options' + +const SECRET_SCOPE_OPTIONS = [ + { value: 'all', label: 'All secrets' }, + { value: 'selected', label: 'Selected secrets' }, +] + +const DROPDOWN_TRIGGER_CLASS = 'w-[240px] flex-shrink-0' export function InboxSettingsTab() { const params = useParams() @@ -33,6 +44,7 @@ export function InboxSettingsTab() { const { data: config } = useInboxConfig(workspaceId) const { data: sendersData, isLoading: sendersLoading } = useInboxSenders(workspaceId) const updateAddress = useUpdateInboxAddress() + const updateSecretPolicy = useUpdateInboxSecretPolicy() const addSender = useAddInboxSender() const removeSender = useRemoveInboxSender() @@ -47,6 +59,11 @@ export function InboxSettingsTab() { const [removeSenderError, setRemoveSenderError] = useState(null) const { copied: copiedAddress, copy } = useCopyToClipboard() + const { options: secretOptions, isPending: secretOptionsPending } = + useRawMountableSecretOptions(workspaceId) + + const secretScope = config?.secretScope ?? 'all' + const mountedSecrets = config?.mountedSecrets ?? [] const handleCopyAddress = useCallback(() => { if (config?.address) void copy(config.address) @@ -228,6 +245,60 @@ export function InboxSettingsTab() {
+ + +
+
+ +
+ + updateSecretPolicy.mutate({ + workspaceId, + secretScope: value === 'selected' ? 'selected' : 'all', + mountedSecrets, + }) + } + options={SECRET_SCOPE_OPTIONS} + disabled={updateSecretPolicy.isPending} + /> +
+
+ + {secretScope === 'selected' && ( +
+ +
+ + updateSecretPolicy.mutate({ + workspaceId, + secretScope: 'selected', + mountedSecrets: values, + }) + } + disabled={secretOptionsPending || updateSecretPolicy.isPending} + /> +
+
+ )} +
+
) ) : ( -
+
{filteredTasks.map((task) => { const statusBadge = STATUS_BADGES[task.status] || STATUS_BADGES.received const isClickable = @@ -177,9 +179,7 @@ export function InboxTaskList() { )} {statusBadge.label} - {isClickable && ( - - )} + {isClickable && }
) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index 5558e23fb35..04158338a79 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -8,6 +8,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { ChevronDown, Plus } from 'lucide-react' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' +import { McpIcon } from '@/components/icons' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { requestJson } from '@/lib/api/client/request' import { getWorkflowStateContract } from '@/lib/api/contracts/workflows' @@ -25,9 +26,13 @@ import { } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { getRefreshActionState } from '@/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state' import { getServerToolsLabel } from '@/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup' @@ -67,12 +72,10 @@ interface ServerListItemProps { canManage: boolean server: McpServer tools: McpTool[] - isDeleting: boolean isConnecting: boolean isLoadingTools?: boolean isRefreshing?: boolean discoveryError?: string | null - onRemove: () => void onViewDetails: () => void onAuthorize: () => void } @@ -81,12 +84,10 @@ function ServerListItem({ canManage, server, tools, - isDeleting, isConnecting, isLoadingTools = false, isRefreshing = false, discoveryError = null, - onRemove, onViewDetails, onAuthorize, }: ServerListItemProps) { @@ -110,56 +111,46 @@ function ServerListItem({ server.connectionStatus === 'disconnected' || showDiscoveryError + const serverName = server.name || 'Unnamed server' + // Transport rides on the description rather than beside the name — inside the + // row's truncating title a long name would clip it away entirely. + const statusText = isConnecting + ? 'Waiting for authorization...' + : isRefreshing + ? 'Refreshing...' + : isLoadingTools && tools.length === 0 + ? 'Loading...' + : showDiscoveryError + ? discoveryError + : toolsLabel + return ( -
-
-
- - {server.name || 'Unnamed server'} + } + iconFilled + title={serverName} + description={ + <> + {`${transportLabel} · `} + {/* Only the status reddens — the transport is neutral metadata. */} + + {statusText} - ({transportLabel}) -
-

- {isConnecting - ? 'Waiting for authorization...' - : isRefreshing - ? 'Refreshing...' - : isLoadingTools && tools.length === 0 - ? 'Loading...' - : showDiscoveryError - ? discoveryError - : toolsLabel} -

-
-
- {canManage && server.authType === 'oauth' && server.connectionStatus !== 'connected' && ( + + } + onClick={onViewDetails} + clickLabel={`Open ${serverName}`} + navigable + trailing={ + canManage && server.authType === 'oauth' && server.connectionStatus !== 'connected' ? ( {isConnecting ? 'Reopen authorization' : 'Authorize'} - )} - -
-
+ ) : undefined + } + /> ) } @@ -247,6 +238,9 @@ export function MCP() { try { await deleteServerMutation.mutateAsync({ workspaceId, serverId }) + // Deleting from the detail view leaves a dead id in the URL — drop it so Back + // doesn't land on a server that no longer exists. + if (selectedServerId === serverId) handleBackToList() logger.info(`Removed MCP server: ${serverId}`) } catch (error) { logger.error('Failed to remove MCP server:', error) @@ -402,6 +396,28 @@ export function MCP() { const hasServers = servers && servers.length > 0 const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0 + // Delete is reachable from both the list and the detail header, so the confirm + // modal has to render in whichever branch is mounted. + const deleteConfirmModal = canEdit ? ( + { + if (!open) setServerToDeleteId(null) + }} + srTitle='Delete MCP server' + title='Delete MCP server' + text={[ + 'Are you sure you want to delete ', + { + text: servers.find((s) => s.id === serverToDeleteId)?.name || 'this server', + bold: true, + }, + '? This action cannot be undone.', + ]} + confirm={{ label: 'Delete', onClick: confirmDeleteServer }} + /> + ) : null + if (selectedServer) { const { server, tools } = selectedServer const transportLabel = formatTransportLabel(server.transport || 'http') @@ -429,32 +445,30 @@ export function MCP() { text: 'Edit', onSelect: () => setEditingServerId(server.id), }, + { + id: 'delete', + text: deletingServers.has(server.id) ? 'Deleting...' : 'Delete', + onSelect: () => handleRemoveServer(server.id), + disabled: deletingServers.has(server.id), + }, ] : [] } >
-
- Server name -

{server.name || 'Unnamed server'}

-
+ {server.name || 'Unnamed server'} -
- Transport -

{transportLabel}

-
+ {transportLabel} {server.url && ( -
- URL -

{server.url}

-
+ + {server.url} + )} {server.connectionStatus !== 'connected' && ( -
- Status +

{getServerToolsLabel( [], @@ -463,12 +477,11 @@ export function MCP() { server.authType )}

-
+ )} {canEdit && server.authType === 'oauth' && server.connectionStatus !== 'connected' && ( -
- Authentication +
-
+ )}
@@ -551,7 +564,7 @@ export function MCP() { {isExpanded && hasParams && (
-

+

Parameters

@@ -586,7 +599,7 @@ export function MCP() { )}
{paramDesc && ( -

+

{paramDesc}

)} @@ -628,6 +641,7 @@ export function MCP() { allowedMcpDomains={allowedMcpDomains} /> )} + {deleteConfirmModal} ) } @@ -655,11 +669,9 @@ export function MCP() { } > {listError ? ( -
-

- {getErrorMessage(listError, 'Failed to load MCP servers')} -

-
+ + {getErrorMessage(listError, 'Failed to load MCP servers')} + ) : serversLoading ? ( Loading... ) : !hasServers ? ( @@ -667,7 +679,7 @@ export function MCP() { {canEdit ? 'Click "Add server" above to get started' : 'No MCP servers configured'} ) : ( -
+
{filteredServers.map((server) => { if (!server?.id) return null const tools = toolsByServer[server.id] || [] @@ -682,7 +694,6 @@ export function MCP() { canManage={canEdit} server={server} tools={tools} - isDeleting={deletingServers.has(server.id)} isConnecting={connectingOauthServers.has(server.id)} isLoadingTools={isLoadingTools} isRefreshing={ @@ -692,7 +703,6 @@ export function MCP() { discoveryError={ serverToolsState?.error ? getErrorMessage(serverToolsState.error) : null } - onRemove={() => handleRemoveServer(server.id)} onViewDetails={() => handleViewDetails(server.id)} onAuthorize={() => startOauthForServer(server.id)} /> @@ -727,25 +737,7 @@ export function MCP() { /> )} - {canEdit && ( - { - if (!open) setServerToDeleteId(null) - }} - srTitle='Delete MCP server' - title='Delete MCP server' - text={[ - 'Are you sure you want to delete ', - { - text: servers.find((s) => s.id === serverToDeleteId)?.name || 'this server', - bold: true, - }, - '? This action cannot be undone.', - ]} - confirm={{ label: 'Delete', onClick: confirmDeleteServer }} - /> - )} + {deleteConfirmModal} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index df8930fe4ac..3bf5fafbdad 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -22,7 +22,10 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useFolders, useRestoreFolder } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge' @@ -526,11 +529,9 @@ export function RecentlyDeleted() { /> {error ? ( -
-

- {toError(error).message || 'Failed to load deleted items'} -

-
+ + {toError(error).message || 'Failed to load deleted items'} + ) : isLoading ? null : filtered.length === 0 ? ( showNoResults ? ( @@ -540,7 +541,7 @@ export function RecentlyDeleted() { No deleted items ) ) : ( -
+
{filtered.map((resource) => { const isRestoring = restoringIds.has(resource.id) const isRestored = restoredItems.has(resource.id) @@ -561,22 +562,24 @@ export function RecentlyDeleted() { Deleted {formatDate(resource.deletedAt)} } + badge={ + canRestore && isRestored ? ( + + {PAUSED_AUTOMATION_TYPES.has(resource.type) + ? 'Restored \u00b7 schedules and webhooks stay paused' + : 'Restored'} + + ) : undefined + } trailing={ !canRestore ? null : isRestoring ? ( Restoring... ) : isRestored ? ( -
- - {PAUSED_AUTOMATION_TYPES.has(resource.type) - ? 'Restored \u00b7 schedules and webhooks stay paused' - : 'Restored'} - - handleView(resource)}> - View - -
+ handleView(resource)}> + View + ) : ( void handleRestore(resource)}> Restore diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx index c6887f23277..c5cb959405f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx @@ -9,6 +9,7 @@ import { type SandboxDraft, type SandboxLanguage, } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import type { Sandbox } from '@/hooks/queries/sandboxes' @@ -41,8 +42,7 @@ export function SandboxEditor({
-
- Name + onChange({ ...draft, name: event.target.value })} @@ -51,9 +51,8 @@ export function SandboxEditor({ maxLength={64} autoComplete='off' /> -
-
- Language + + onChange({ ...draft, language: language as SandboxLanguage })} @@ -63,7 +62,7 @@ export function SandboxEditor({ }))} disabled={disabled} /> -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx index 45c2e437303..c8ab16b44ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { toast } from '@sim/emcn' +import { ChipConfirmModal, toast } from '@sim/emcn' import { ArrowLeft, Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' @@ -12,7 +12,6 @@ import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { SandboxDependencyIssue } from '@/lib/api/contracts/sandboxes' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SandboxEditor, SandboxStatus, @@ -28,12 +27,15 @@ import { SANDBOX_UPGRADE_DESCRIPTION, SANDBOX_UPGRADE_TITLE, type SandboxDraft, + sandboxDeleteConfirmText, toSubmittedLines, } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsUpgradeNotice } from '@/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' @@ -66,6 +68,7 @@ export function Sandboxes() { const [draft, setDraft] = useState(null) const [issues, setIssues] = useState([]) const [isCreating, setIsCreating] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) // The draft belongs to whatever was open when it was typed. Browser Back // clears `selectedId` without going through `closeEditor`, so without this the @@ -77,6 +80,10 @@ export function Sandboxes() { setDraft(null) setIssues([]) } + // The confirmation belongs to the sandbox that opened it. Browser Back unmounts + // the modal without closing it, so leaving this set would re-open it against + // whichever sandbox is selected next — and delete that one instead. + setShowDeleteConfirm(false) // Creating and having one open are mutually exclusive, and history can land on // a sandbox while create mode is still set — Forward after starting a new one. // Leaving both on renders an empty "New sandbox" form whose Delete still points @@ -140,6 +147,7 @@ export function Sandboxes() { const handleDelete = useCallback( async (sandbox: Sandbox) => { + setShowDeleteConfirm(false) try { await deleteSandbox.mutateAsync({ workspaceId, sandboxId: sandbox.id }) if (selectedId === sandbox.id) closeEditor() @@ -207,9 +215,9 @@ export function Sandboxes() { ...(selected && canAdmin ? [ { - text: 'Delete', - textTone: 'error' as const, - onSelect: () => void handleDelete(selected), + id: 'delete', + text: deleteSandbox.isPending ? 'Deleting...' : 'Delete', + onSelect: () => setShowDeleteConfirm(true), disabled: deleteSandbox.isPending, }, ] @@ -225,6 +233,17 @@ export function Sandboxes() { /> + {selected && ( + void handleDelete(selected) }} + /> + )} + - - {filtered.length === 0 ? ( - - {searchTerm - ? 'No sandboxes match your search.' - : 'No sandboxes yet. Create one to let Function blocks import packages.'} - - ) : ( -
- {filtered.map((sandbox) => ( - } - title={ - - } - description={`${sandbox.language === 'python' ? 'Python' : 'JavaScript'} · ${sandbox.dependencies.length} ${sandbox.dependencies.length === 1 ? 'package' : 'packages'}`} - trailing={ - canAdmin ? ( - void setSelectedId(sandbox.id) }, - { - label: 'Delete', - destructive: true, - onSelect: () => void handleDelete(sandbox), - }, - ]} - /> - ) : undefined - } - /> - ))} -
- )} -
+ {filtered.length === 0 ? ( + + {searchTerm + ? 'No sandboxes match your search.' + : 'No sandboxes yet. Create one to let Function blocks import packages.'} + + ) : ( +
+ {filtered.map((sandbox) => ( + } + iconFilled + title={sandbox.name} + description={`${sandbox.language === 'python' ? 'Python' : 'JavaScript'} · ${sandbox.dependencies.length} ${sandbox.dependencies.length === 1 ? 'package' : 'packages'}`} + onClick={() => void setSelectedId(sandbox.id)} + clickLabel={`Open ${sandbox.name}`} + navigable + /> + ))} +
+ )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts index ae5c909621e..8ea2b721667 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts @@ -7,6 +7,20 @@ export const SANDBOX_UPGRADE_TITLE = 'Sandboxes require an active Max plan' export const SANDBOX_UPGRADE_DESCRIPTION = 'Upgrade to Max and ensure billing is active to install Python or npm packages that your Function blocks can import.' +/** Delete-confirmation copy, matching the custom tool detail's wording. Names the + * sandbox so the dialog is self-evidently about the one you opened it from. */ +export function sandboxDeleteConfirmText(name: string) { + return [ + 'This will permanently delete ', + { text: name, bold: true }, + { + text: ' and remove it from any Function blocks that are using it.', + error: true, + }, + ' This action cannot be undone.', + ] +} + /** Ordered to match the Function block's own `language` dropdown. */ export const LANGUAGE_OPTIONS = [ { label: 'JavaScript', value: 'javascript' }, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index 4b6d58ee8ab..2ad13f3d873 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -19,6 +19,7 @@ import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/component import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { isValidEnvVarName } from '@/executor/constants' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' @@ -999,9 +1000,7 @@ export function SecretsManager() { {(!searchTerm.trim() || filteredWorkspaceEntries.length > 0 || filteredNewWorkspaceRows.length > 0) && ( -
- Workspace -
+
{(searchTerm.trim() ? filteredWorkspaceEntries @@ -1044,13 +1043,11 @@ export function SecretsManager() { /> ))}
-
+ )} {(!searchTerm.trim() || filteredEnvVars.length > 0) && ( -
- Personal -
+
{filteredEnvVars.map(({ envVar, originalIndex }) => (
@@ -1058,7 +1055,7 @@ export function SecretsManager() {
))}
-
+ )} {searchTerm.trim() && filteredEnvVars.length === 0 && diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx index c63457a65d6..6e1cc077ce4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx @@ -9,6 +9,8 @@ interface SettingsEmptyStateProps { * matched nothing. Defaults to `fill`. */ variant?: 'fill' | 'inline' + /** Renders the message in the error tone, for a failed load. */ + tone?: 'muted' | 'error' } /** @@ -16,11 +18,16 @@ interface SettingsEmptyStateProps { * "no results", and entitlement/loading gates. Centralizes the text token and * spacing so every settings page reads identically. */ -export function SettingsEmptyState({ children, variant = 'fill' }: SettingsEmptyStateProps) { +export function SettingsEmptyState({ + children, + variant = 'fill', + tone = 'muted', +}: SettingsEmptyStateProps) { return (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/index.ts new file mode 100644 index 00000000000..2f151bb85c2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/index.ts @@ -0,0 +1 @@ +export { SettingsField } from './settings-field' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/settings-field.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/settings-field.tsx new file mode 100644 index 00000000000..3a8072eb3df --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/settings-field.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' + +interface SettingsFieldProps { + label: ReactNode + /** Wraps long unbroken values (a URL, a key) instead of overflowing. */ + breakAll?: boolean + children: ReactNode +} + +/** + * A read-only label/value pair inside a settings detail body: a muted caption + * over the value. Single source for that pairing — before this, the same field + * was hand-rolled with three different label sizes and three different gaps. + * + * Renders the value paragraph itself, so callers never restate its type tokens. + * Pass a node instead of text only when the value is a control (a chip, a link). + */ +export function SettingsField({ label, breakAll = false, children }: SettingsFieldProps) { + return ( +
+ {label} + {typeof children === 'string' ? ( +

{children}

+ ) : ( + children + )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts index da29f86f11c..335ecd0a0cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts @@ -1 +1,6 @@ -export { SettingsResourceRow } from './settings-resource-row' +export { + RESOURCE_LIST_GRID, + RESOURCE_LIST_STACK, + RESOURCE_ROW_ARROW_CLASSES, + SettingsResourceRow, +} from './settings-resource-row' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx index d5481416753..01f354c27b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx @@ -1,8 +1,11 @@ -import type { ReactNode } from 'react' +import { type ReactNode, useId } from 'react' import { cn } from '@sim/emcn' +import { ArrowRight } from '@sim/emcn/icons' +import Link from 'next/link' import { RESOURCE_TILE_BASE, RESOURCE_TILE_FILL, + RESOURCE_TILE_PLAIN, } from '@/app/workspace/[workspaceId]/components/resource-tile' /** @@ -16,15 +19,20 @@ import { * contains to 20px, so callers pass their raw icon node without pre-sizing it. */ interface SettingsResourceRowProps { - /** Icon node centered in the tile; a `` is normalized to 20px, an `` to 20px (or the full tile when `iconFill`). */ - icon: ReactNode + /** + * Icon node centered in the tile; a `` is normalized to 20px, an `` to + * 20px (or the full tile when `iconFill`). Omit it for rows whose resource has no + * identity glyph (an API key, a permission group) — the row then leads with text. + */ + icon?: ReactNode /** * Icon chrome. `tile` (default) is the bordered 36px tile for brand/logo and * resource icons; `plain` drops the tile for a bare 14px glyph in * `--text-icon`, for rows whose icon is a type marker rather than an identity - * (e.g. a folder on disk). + * (e.g. a folder on disk); `custom` renders `icon` verbatim, for callers that + * must supply their own tile (e.g. the brand-tinted `IntegrationTile`). */ - iconVariant?: 'tile' | 'plain' + iconVariant?: 'tile' | 'plain' | 'custom' /** * Let an image icon fill the tile edge-to-edge instead of clamping to 20px. * Use for uploaded image/logo icons (e.g. custom blocks); glyph ``s still @@ -41,20 +49,72 @@ interface SettingsResourceRowProps { /** Secondary muted line — truncates. */ description?: ReactNode /** - * Trailing element pinned to the row's end (chips, actions menu, status). The row - * keeps it at its natural size — callers never need their own `flex-shrink-0`. + * Interactive controls pinned to the row's end (chips, actions menu). These sit + * ABOVE the row's own hit area, so their clicks are theirs. The row keeps them at + * their natural size — callers never need their own `flex-shrink-0`. + * + * Decorative trailing content (a status badge, a tag) belongs in {@link badge}: + * anything placed here swallows clicks meant for the row. */ trailing?: ReactNode /** - * Makes the icon + text cluster activatable. `trailing` stays a sibling, so - * its own controls keep working — never nest an interactive `trailing` inside - * the row's own hit area. + * Decorative trailing content — a status badge or tag. Rendered before + * {@link trailing} and made click-through, so it never turns the row's right + * edge into a dead zone. + */ + badge?: ReactNode + /** + * Makes the whole row activatable via a stretched overlay button. `trailing` + * stacks above it, so interactive trailing controls (menus, chips) keep + * working — never nest an interactive `trailing` inside a caller-supplied + * wrapper `
- {icon} -
+ {icon == null ? null : iconVariant === 'custom' ? ( + icon + ) : ( +
+ {icon} +
+ )}
{title} {description != null && ( - {description} + + {description} + )}
) - const clusterClass = cn('flex min-w-0 items-center', isTile ? 'gap-2.5' : 'gap-2') + const clusterClass = cn( + 'flex min-w-0 items-center', + iconVariant === 'plain' ? 'gap-2' : 'gap-2.5' + ) + const hasEnd = badge != null || trailing != null || navigable + // Decoration and the chevron stay click-through so the row's right edge never + // becomes a dead zone; only `trailing` takes pointer events back. + const end = hasEnd ? ( +
+ {badge} + {trailing != null &&
{trailing}
} + {navigable && } +
+ ) : null + + // Row geometry is identical whether or not the row is activatable, so a list + // mixing clickable and static rows keeps one height and one inset. + const rowClass = cn('flex items-center justify-between gap-2.5', !flush && '-mx-2 rounded-lg p-2') + if (!onClick && !href) { + return ( +
+
{cluster}
+ {end} +
+ ) + } + + // The ring renders on the stretched overlay, which is inset-0 over the row — so a + // keyboard focus outline traces the visible row even though the control is empty. + const overlayClass = + 'absolute inset-0 cursor-pointer rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color-mix(in_srgb,var(--text-muted)_30%,transparent)]' + + // The hit area is a stretched overlay rather than a wrapper around the cluster: + // it lets the hover band span the full row (matching every hand-rolled settings + // list) while `trailing` — which may hold its own buttons — stacks above it. return ( -
- {onClick ? ( +
+ {href ? ( + + ) : ( - ) : ( -
{cluster}
+ aria-describedby={description != null ? describedById : undefined} + className={overlayClass} + /> )} - {trailing ?
{trailing}
: null} +
{cluster}
+ {end}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx index 18fa43c9315..6e80f8b985e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' interface SettingsSectionProps { - label: string + label: ReactNode /** Optional node rendered immediately to the right of the label (e.g. an info tooltip). */ headerAccessory?: ReactNode /** Optional control pinned to the far right of the header row (e.g. a Select All chip). */ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx index d5063475eee..2417477fb83 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx @@ -123,7 +123,9 @@ export function CreateWorkflowMcpServerModal({ Public {formData.isPublic && ( - No authentication required + + No authentication required + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 69fed161b7e..53e4a100ec0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -27,6 +27,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { Check, Clipboard, Plus, Server } from 'lucide-react' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' +import { McpIcon } from '@/components/icons' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { getBaseUrl } from '@/lib/core/utils/urls' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -39,8 +40,13 @@ import { import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { CreateWorkflowMcpServerModal } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components' import { useApiKeys } from '@/hooks/queries/api-keys' @@ -65,11 +71,22 @@ interface ServerDetailViewProps { workspaceId: string serverId: string onBack: () => void + /** Opens the parent's delete confirmation — the modal lives with the mutation. + * Absent until the parent's list resolves, so a deep link never shows an inert Delete. */ + onDelete?: () => void + isDeleting: boolean } type McpClientType = 'sim' | 'cursor' | 'claude-code' | 'claude-desktop' | 'vscode' -function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDetailViewProps) { +function ServerDetailView({ + canManage, + workspaceId, + serverId, + onBack, + onDelete, + isDeleting, +}: ServerDetailViewProps) { const { data, isLoading, error } = useWorkflowMcpServer(workspaceId, serverId) const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } = useDeployedWorkflows(workspaceId) @@ -363,11 +380,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe if (error || !data) { return ( -
-

- Failed to load server details -

-
+ Failed to load server details
) } @@ -393,6 +406,16 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe ? 'All deployed workflows have been added to this server.' : undefined, }, + ...(onDelete + ? [ + { + id: 'delete', + text: isDeleting ? 'Deleting...' : 'Delete', + onSelect: onDelete, + disabled: isDeleting, + }, + ] + : []), ] : [] } @@ -410,25 +433,20 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe
{activeServerTab === 'workflows' && (
- Workflows - {tools.length === 0 ? (

No workflows added yet. Click "Add Workflow" to add a deployed workflow.

) : ( -
+
{tools.map((tool) => ( -
-
- {tool.toolName} -

- {tool.toolDescription || 'No description'} -

-
- {canManage && ( -
+ -
- )} -
+ ) : undefined + } + /> ))}
)} {deployedWorkflows.length === 0 && !isLoadingWorkflows && ( -

+

Deploy a workflow first to add it to this server.

)} @@ -459,43 +477,24 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe {activeServerTab === 'details' && (
-
- - Server Name - -

{server.name}

-
-
- - Transport - -

Streamable-HTTP

-
-
- Access -

- {server.isPublic ? 'Public' : 'API Key'} -

-
+ {server.name} + Streamable-HTTP + + {server.isPublic ? 'Public' : 'API Key'} +
{server.description?.trim() && ( -
- - Description - -

{server.description}

-
+ {server.description} )} -
- URL -

{mcpServerUrl}

-
+ + {mcpServerUrl} +
- + MCP Client
@@ -563,7 +562,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe )} {addToWorkspaceMutation.isError && ( -

+

{addToWorkspaceMutation.error?.message || 'Failed to add server'}

)} @@ -609,7 +608,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe )}
{!server.isPublic && ( -

+

Replace $SIM_API_KEY with your API key {canManage && ( <> @@ -632,244 +631,235 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe

+ canManage && ( + !open && setToolToDelete(null)} + srTitle='Remove Workflow' + title='Remove Workflow' + text={[ + 'Are you sure you want to remove ', + { text: toolToDelete?.toolName ?? 'this workflow', bold: true }, + ' from this server? The workflow will remain deployed and can be added back later.', + ]} + confirm={{ + label: 'Remove', + onClick: handleDeleteTool, + pending: deleteToolMutation.isPending, + pendingLabel: 'Removing...', + }} + /> + )canManage && ( + { + if (!open) { + setToolToView(null) + setEditingDescription('') + setEditingParameterDescriptions({}) + } + }} + srTitle={toolToView?.toolName ?? 'Edit Tool'} + > + setToolToView(null)}> + {toolToView?.toolName} + + + - {canManage && ( - !open && setToolToDelete(null)} - srTitle='Remove Workflow' - title='Remove Workflow' - text={[ - 'Are you sure you want to remove ', - { text: toolToDelete?.toolName ?? 'this workflow', bold: true }, - ' from this server? The workflow will remain deployed and can be added back later.', - ]} - confirm={{ - label: 'Remove', - onClick: handleDeleteTool, - pending: deleteToolMutation.isPending, - pendingLabel: 'Removing...', - }} - /> - )} - - {canManage && ( - { - if (!open) { - setToolToView(null) - setEditingDescription('') - setEditingParameterDescriptions({}) - } - }} - srTitle={toolToView?.toolName ?? 'Edit Tool'} - > - setToolToView(null)}> - {toolToView?.toolName} - - - - - - {(() => { - const schema = toolToView?.parameterSchema as - | { properties?: Record } - | undefined - const properties = schema?.properties - const hasParams = properties && Object.keys(properties).length > 0 - return hasParams ? ( -
- {Object.entries(properties).map(([name, prop]) => ( -
-
-
- - {name} - - - {prop.type || 'any'} - -
+ + {(() => { + const schema = toolToView?.parameterSchema as + | { properties?: Record } + | undefined + const properties = schema?.properties + const hasParams = properties && Object.keys(properties).length > 0 + return hasParams ? ( +
+ {Object.entries(properties).map(([name, prop]) => ( +
+
+
+ + {name} + + + {prop.type || 'any'} +
-
-
- - - setEditingParameterDescriptions((prev) => ({ - ...prev, - [name]: e.target.value, - })) - } - placeholder={`Enter description for ${name}`} - /> -
+
+
+
+ + + setEditingParameterDescriptions((prev) => ({ + ...prev, + [name]: e.target.value, + })) + } + placeholder={`Enter description for ${name}`} + />
- ))} -
- ) : ( -

- No inputs configured for this workflow. -

- ) - })()} - - - setToolToView(null)} - primaryAction={{ - label: updateToolMutation.isPending ? 'Saving...' : 'Save', - onClick: handleSaveToolEdit, - disabled: isSaveToolDisabled, - }} - /> - - )} - - {canManage && ( - { - if (!open) { - setShowAddWorkflow(false) - setSelectedWorkflowId(null) - } +
+ ))} +
+ ) : ( +

+ No inputs configured for this workflow. +

+ ) + })()} +
+ + setToolToView(null)} + primaryAction={{ + label: updateToolMutation.isPending ? 'Saving...' : 'Save', + onClick: handleSaveToolEdit, + disabled: isSaveToolDisabled, }} - srTitle='Add Workflow' - > - { - setShowAddWorkflow(false) - setSelectedWorkflowId(null) - }} - > - Add Workflow - - -

- Select a deployed workflow to add to this MCP server. The workflow will be available - as a tool. -

- - setSelectedWorkflowId(value)} - placeholder='Select a workflow...' - searchable - searchPlaceholder='Search workflows...' - disabled={addToolMutation.isPending} - fullWidth - dropdownWidth='trigger' - align='start' - displayLabel={selectedWorkflow?.name} - /> - - - {addToolMutation.isError - ? addToolMutation.error?.message || 'Failed to add workflow' - : null} - -
- { - setShowAddWorkflow(false) - setSelectedWorkflowId(null) - }} - primaryAction={{ - label: addToolMutation.isPending ? 'Adding...' : 'Add Workflow', - onClick: handleAddWorkflow, - disabled: !selectedWorkflowId || addToolMutation.isPending, - }} - /> - - )} - - {canManage && ( - { - if (!open) { - setShowEditServer(false) - } + /> + + )canManage && ( + { + if (!open) { + setShowAddWorkflow(false) + setSelectedWorkflowId(null) + } + }} + srTitle='Add Workflow' + > + { + setShowAddWorkflow(false) + setSelectedWorkflowId(null) }} - srTitle='Edit Server' > - setShowEditServer(false)}>Edit Server - - - + +

+ Select a deployed workflow to add to this MCP server. The workflow will be available as + a tool. +

+ + setSelectedWorkflowId(value)} + placeholder='Select a workflow...' + searchable + searchPlaceholder='Search workflows...' + disabled={addToolMutation.isPending} + fullWidth + dropdownWidth='trigger' + align='start' + displayLabel={selectedWorkflow?.name} /> - -
- setEditServerIsPublic(value === 'public')} - > - API Key - Public - -

- {editServerIsPublic - ? 'Anyone with the URL can call this server without authentication' - : 'Requests must include your Sim API key in the X-API-Key header'} -

-
-
-
- setShowEditServer(false)} - primaryAction={{ - label: updateServerMutation.isPending ? 'Saving...' : 'Save', - onClick: handleSaveServerEdit, - disabled: - !editServerName.trim() || - updateServerMutation.isPending || - (editServerName === server.name && - editServerDescription === (server.description || '') && - editServerIsPublic === server.isPublic), - }} +
+ + {addToolMutation.isError + ? addToolMutation.error?.message || 'Failed to add workflow' + : null} + +
+ { + setShowAddWorkflow(false) + setSelectedWorkflowId(null) + }} + primaryAction={{ + label: addToolMutation.isPending ? 'Adding...' : 'Add Workflow', + onClick: handleAddWorkflow, + disabled: !selectedWorkflowId || addToolMutation.isPending, + }} + /> +
+ )canManage && ( + { + if (!open) { + setShowEditServer(false) + } + }} + srTitle='Edit Server' + > + setShowEditServer(false)}>Edit Server + + - - )} - - {canManage && ( - + +
+ setEditServerIsPublic(value === 'public')} + > + API Key + Public + +

+ {editServerIsPublic + ? 'Anyone with the URL can call this server without authentication' + : 'Requests must include your Sim API key in the X-API-Key header'} +

+
+
+ + setShowEditServer(false)} + primaryAction={{ + label: updateServerMutation.isPending ? 'Saving...' : 'Save', + onClick: handleSaveServerEdit, + disabled: + !editServerName.trim() || + updateServerMutation.isPending || + (editServerName === server.name && + editServerDescription === (server.description || '') && + editServerIsPublic === server.isPublic), + }} /> - )} + + )canManage && ( + + ) ) } @@ -926,6 +916,12 @@ export function WorkflowMcpServers() { workspaceId, serverId: serverToDelete.id, }) + // Deleting from the detail view leaves a dead id in the URL; on reload the + // detail branch mounts against a server that no longer exists. + if (selectedServerId === serverToDelete.id) { + void setServerTab(null, { history: 'replace' }) + void setSelectedServerId(null, { history: 'replace' }) + } } catch (err) { logger.error('Failed to delete server:', err) } finally { @@ -951,17 +947,40 @@ export function WorkflowMcpServers() { const selectedServerResolves = selectedServerId !== null && (isLoading || servers.some((s) => s.id === selectedServerId)) + // Delete is reachable from both the list and the detail header, so the confirm + // modal has to render in whichever branch is mounted. + const deleteConfirmModal = canAdmin ? ( + !open && setServerToDelete(null)} + srTitle='Delete MCP Server' + title='Delete MCP Server' + text={[ + 'Are you sure you want to delete ', + { text: serverToDelete?.name ?? 'this server', bold: true }, + '? This action cannot be undone.', + ]} + confirm={{ label: 'Delete', onClick: handleDeleteServer }} + /> + ) : null + if (selectedServerId && selectedServerResolves) { + const selectedServer = servers.find((s) => s.id === selectedServerId) return ( - { - void setServerTab(null, { history: 'replace' }) - void setSelectedServerId(null, { history: 'replace' }) - }} - /> + <> + { + void setServerTab(null, { history: 'replace' }) + void setSelectedServerId(null, { history: 'replace' }) + }} + onDelete={selectedServer ? () => setServerToDelete(selectedServer) : undefined} + isDeleting={deletingServers.has(selectedServerId)} + /> + {deleteConfirmModal} + ) } @@ -989,62 +1008,42 @@ export function WorkflowMcpServers() { >
{error ? ( -
-

- {getErrorMessage(error, 'Failed to load MCP servers')} -

-
+ + {getErrorMessage(error, 'Failed to load MCP servers')} + ) : isLoading ? null : !hasServers ? ( {canAdmin ? 'Click "Add server" above to get started' : 'No MCP servers configured'} ) : ( -
+
{filteredServers.map((server) => { const count = server.toolCount || 0 const toolsLabel = `${count} tool${count !== 1 ? 's' : ''}` - const isDeleting = deletingServers.has(server.id) return ( -
-
-
- - {server.name} - - {server.isPublic && ( - - Public - - )} -
-

{toolsLabel}

-
-
- { - // A lingering ?server-tab= (dead deep link) must not re-target the next open — reset it in the same batched push. - void setServerTab(null) - void setSelectedServerId(server.id) - }, - }, - ...(canAdmin - ? [ - { - label: 'Delete', - destructive: true, - disabled: isDeleting, - onSelect: () => setServerToDelete(server), - }, - ] - : []), - ]} - /> -
-
+ } + iconFilled + title={server.name} + description={toolsLabel} + onClick={() => { + // A lingering ?server-tab= (dead deep link) must not re-target the next open — reset it in the same batched push. + void setServerTab(null) + void setSelectedServerId(server.id) + }} + clickLabel={`Open ${server.name}`} + navigable + // The badge sits at the row's end, not beside the name — the + // title truncates, so a long name would clip it out of view. + badge={ + server.isPublic ? ( + + Public + + ) : undefined + } + /> ) })} {showNoResults && ( @@ -1066,20 +1065,7 @@ export function WorkflowMcpServers() { /> )} - {canAdmin && ( - !open && setServerToDelete(null)} - srTitle='Delete MCP Server' - title='Delete MCP Server' - text={[ - 'Are you sure you want to delete ', - { text: serverToDelete?.name ?? 'this server', bold: true }, - '? This action cannot be undone.', - ]} - confirm={{ label: 'Delete', onClick: handleDeleteServer }} - /> - )} + {deleteConfirmModal} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index 5c2a5bfe3e8..ed53da7b24d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -21,50 +21,88 @@ import { } from '@/app/workspace/[workspaceId]/settings/navigation' describe('unified settings navigation', () => { - it('preserves the original settings groups', () => { + it('groups settings by the scope they affect', () => { expect(sectionConfig).toEqual([ { key: 'account', title: 'Account' }, - { key: 'tools', title: 'Tools' }, - { key: 'subscription', title: 'Subscription' }, - { key: 'system', title: 'System' }, - { key: 'desktop', title: 'Desktop' }, - { key: 'enterprise', title: 'Enterprise' }, - { key: 'superuser', title: 'Superuser' }, + { key: 'workspace', title: 'Workspace' }, + { key: 'organization', title: 'Organization' }, + { key: 'platform', title: 'Platform' }, ]) }) it('keeps account, workspace, organization, and platform settings in one catalog', () => { expect(allNavigationItems.map(({ id, label, section }) => ({ id, label, section }))).toEqual([ { id: 'general', label: 'General', section: 'account' }, - { id: 'desktop', label: 'Desktop', section: 'desktop' }, - { id: 'browser', label: 'Browser', section: 'desktop' }, - { id: 'terminal', label: 'Terminal', section: 'desktop' }, - { id: 'access-control', label: 'Access control', section: 'enterprise' }, - { id: 'audit-logs', label: 'Audit logs', section: 'enterprise' }, - { id: 'forks', label: 'Workspace Forks', section: 'enterprise' }, - { id: 'billing', label: 'Billing', section: 'subscription' }, - { id: 'teammates', label: 'Teammates', section: 'subscription' }, - { id: 'organization', label: 'Organization', section: 'subscription' }, - { id: 'secrets', label: 'Secrets', section: 'account' }, - { id: 'custom-tools', label: 'Custom tools', section: 'tools' }, - { id: 'mcp', label: 'MCP tools', section: 'tools' }, - { id: 'apikeys', label: 'Sim API keys', section: 'system' }, - { id: 'workflow-mcp-servers', label: 'MCP servers', section: 'system' }, - { id: 'byok', label: 'BYOK', section: 'system' }, - { id: 'sandboxes', label: 'Sandboxes', section: 'system' }, - { id: 'inbox', label: 'Sim mailer', section: 'system' }, - { id: 'recently-deleted', label: 'Recently deleted', section: 'system' }, - { id: 'sso', label: 'Single sign-on', section: 'enterprise' }, - { id: 'sessions', label: 'Session policies', section: 'enterprise' }, - { id: 'data-retention', label: 'Data retention', section: 'enterprise' }, - { id: 'data-drains', label: 'Data drains', section: 'enterprise' }, - { id: 'whitelabeling', label: 'Whitelabeling', section: 'enterprise' }, - { id: 'custom-blocks', label: 'Custom blocks', section: 'enterprise' }, - { id: 'admin', label: 'Admin', section: 'superuser' }, - { id: 'mothership', label: 'Mothership', section: 'superuser' }, + { id: 'desktop', label: 'Desktop', section: 'account' }, + { id: 'browser', label: 'Browser', section: 'account' }, + { id: 'terminal', label: 'Terminal', section: 'account' }, + { id: 'access-control', label: 'Permission groups', section: 'organization' }, + { id: 'audit-logs', label: 'Audit logs', section: 'organization' }, + { id: 'forks', label: 'Workspace forks', section: 'organization' }, + { id: 'billing', label: 'Subscription', section: 'account' }, + { id: 'teammates', label: 'Teammates', section: 'workspace' }, + { id: 'organization', label: 'Members', section: 'organization' }, + { id: 'secrets', label: 'Secrets', section: 'workspace' }, + { id: 'custom-tools', label: 'Custom tools', section: 'workspace' }, + { id: 'mcp', label: 'MCP tools', section: 'workspace' }, + { id: 'apikeys', label: 'Sim API keys', section: 'workspace' }, + { id: 'workflow-mcp-servers', label: 'MCP servers', section: 'workspace' }, + { id: 'byok', label: 'BYOK', section: 'workspace' }, + { id: 'sandboxes', label: 'Sandboxes', section: 'workspace' }, + { id: 'inbox', label: 'Sim Mailer', section: 'workspace' }, + { id: 'recently-deleted', label: 'Recently deleted', section: 'workspace' }, + { id: 'sso', label: 'Single sign-on', section: 'organization' }, + { id: 'sessions', label: 'Session policies', section: 'organization' }, + { id: 'data-retention', label: 'Data retention', section: 'organization' }, + { id: 'data-drains', label: 'Data drains', section: 'organization' }, + { id: 'whitelabeling', label: 'White-labeling', section: 'organization' }, + { id: 'custom-blocks', label: 'Custom blocks', section: 'organization' }, + { id: 'admin', label: 'Admin', section: 'platform' }, + { id: 'mothership', label: 'Mothership', section: 'platform' }, ]) }) + it('orders each scope around its primary settings', () => { + const idsForSection = (section: (typeof sectionConfig)[number]['key']) => + allNavigationItems + .filter((item) => item.section === section) + .sort((left, right) => left.order - right.order) + .map(({ id }) => id) + + expect(idsForSection('account')).toEqual([ + 'general', + 'billing', + 'desktop', + 'browser', + 'terminal', + ]) + expect(idsForSection('workspace')).toEqual([ + 'teammates', + 'secrets', + 'mcp', + 'custom-tools', + 'byok', + 'inbox', + 'workflow-mcp-servers', + 'apikeys', + 'sandboxes', + 'recently-deleted', + ]) + expect(idsForSection('organization')).toEqual([ + 'organization', + 'custom-blocks', + 'forks', + 'access-control', + 'audit-logs', + 'whitelabeling', + 'sso', + 'sessions', + 'data-retention', + 'data-drains', + ]) + expect(idsForSection('platform')).toEqual(['admin', 'mothership']) + }) + it('derives every unified item from exactly one registry entry', () => { expect(allNavigationItems).toHaveLength( SETTINGS_SECTION_REGISTRY.filter(({ unified }) => unified).length diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index bcd36c0299e..d659a983c0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -16,12 +16,9 @@ export const isBillingEnabled = SETTINGS_NAVIGATION_BILLING_ENABLED export const sectionConfig: { key: NavigationSection; title: string }[] = [ { key: 'account', title: 'Account' }, - { key: 'tools', title: 'Tools' }, - { key: 'subscription', title: 'Subscription' }, - { key: 'system', title: 'System' }, - { key: 'desktop', title: 'Desktop' }, - { key: 'enterprise', title: 'Enterprise' }, - { key: 'superuser', title: 'Superuser' }, + { key: 'workspace', title: 'Workspace' }, + { key: 'organization', title: 'Organization' }, + { key: 'platform', title: 'Platform' }, ] export const allNavigationItems: NavigationItem[] = buildUnifiedSettingsNavigation() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx index b1412d0d7f2..985b35649fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx @@ -4,18 +4,19 @@ import { useState } from 'react' import { Chip, ChipCopyInput, ChipLink, Send } from '@sim/emcn' import { ArrowLeft, Key } from '@sim/emcn/icons' import { SaveDiscardChips } from '@/components/settings/save-discard-actions' +import { ResourceTile } from '@/app/workspace/[workspaceId]/components' import { AddPeopleModal, CredentialDetailHeading, CredentialDetailLayout, CredentialMembersSection, - DetailIconTile, DetailSection, UnsavedChangesModal, useUnsavedChangesGuard, } from '@/app/workspace/[workspaceId]/components/credential-detail' import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field' import { useSecretValue } from '@/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { useWorkspaceCredential } from '@/hooks/queries/credentials' interface SecretDetailProps { @@ -65,7 +66,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { if (isPending && !credential) { return ( -

Loading…

+ Loading…
) } @@ -73,7 +74,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { if (!credential) { return ( -

Secret not found.

+ Secret not found.
) } @@ -82,7 +83,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { <> } + leading={} title={credential.envKey || credential.displayName} subtitle={ isPersonal diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx index 9c37ba94fdb..8fe37dcfe52 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx @@ -15,6 +15,7 @@ import { UnsavedChangesModal, useUnsavedChangesGuard, } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SkillEditorsCard } from '@/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card' import { type SkillFieldErrors, @@ -199,7 +200,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { if ((skillsLoading || deleteSkill.isPending || deleteSkill.isSuccess) && !skill) { return ( -

Loading…

+ Loading…
) } @@ -207,7 +208,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { if (!skill) { return ( -

Skill not found.

+ Skill not found.
) } diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx index 2f2dd9ad2ff..b671fb1e84d 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx @@ -3,12 +3,18 @@ import { useEffect, useRef } from 'react' import { Chip, ChipInput, Search } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' -import { ArrowRight, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { SkillTile } from '@/app/workspace/[workspaceId]/components' import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header' import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + RESOURCE_LIST_GRID, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { skillIdParam, skillIdUrlKeys, @@ -20,48 +26,6 @@ import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' const SKILLS_LABEL = 'Skills' -interface SkillItemProps { - name: string - description: string - onClick: () => void -} - -function SkillItem({ name, description, onClick }: SkillItemProps) { - return ( - - ) -} - -interface SkillSectionProps { - label: string - children: React.ReactNode -} - -function SkillSection({ label, children }: SkillSectionProps) { - return ( -
- {label} -
-
- {children} -
-
- ) -} - export function Skills() { const params = useParams() const router = useRouter() @@ -132,30 +96,36 @@ export function Skills() { value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} disabled={isLoading} - className='flex-1' + className='min-w-0 flex-1' />
{error ? ( -
+ {getErrorMessage(error, 'Failed to load skills')} -
+ ) : filteredSkills.length > 0 ? ( - - {filteredSkills.map((s) => ( - router.push(`${skillsHref}/${s.id}`)} - /> - ))} - + +
+ {filteredSkills.map((s) => ( + } + title={s.name} + description={s.description || undefined} + onClick={() => router.push(`${skillsHref}/${s.id}`)} + clickLabel={`Open ${s.name}`} + navigable + /> + ))} +
+
) : showNoResults ? ( -
+ No skills found matching “{searchTerm}” -
+ ) : null}
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx index 808be0af4a0..a8b6cbeae41 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx @@ -1,5 +1,6 @@ 'use client' import { Check, ChipTag, Credit, chipVariants, cn, Info, RefreshCw } from '@sim/emcn' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' /** * Props for {@link UpgradePlanCard}. @@ -125,10 +126,7 @@ export function UpgradePlanCard({ )}
- {/* Section header + divider matching integrations/skills separator language */} -
- {segmentLabel} -
+
    {features.map((feature) => (
  • @@ -137,7 +135,7 @@ export function UpgradePlanCard({
  • ))}
-
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts index c344decafaf..8ca020ea035 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts @@ -928,6 +928,7 @@ interface WorkflowExecutionOptions { stopAfterBlockId?: string abortSignal?: AbortSignal preserveExecutionOnTerminal?: boolean + copilotToolCallId?: string /** For run_from_block / run_block: start from a specific block using cached state */ runFromBlock?: { startBlockId: string @@ -997,6 +998,7 @@ export async function executeWorkflowWithFullLogging( useDraftState: options.useDraftState ?? true, isClientSession: true, ...(options.executionId ? { executionId: options.executionId } : {}), + ...(options.copilotToolCallId ? { copilotToolCallId: options.copilotToolCallId } : {}), ...(options.triggerBlockId ? { triggerBlockId: options.triggerBlockId } : {}), ...(options.stopAfterBlockId ? { stopAfterBlockId: options.stopAfterBlockId } : {}), ...(options.runFromBlock diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 64c756ea880..5654c2e6cb1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -316,7 +316,9 @@ export function SettingsSidebar({ .map(({ key, title }) => ({ key, title, - items: navigationItems.filter((item) => item.section === key), + items: navigationItems + .filter((item) => item.section === key) + .sort((left, right) => left.order - right.order), })) .filter(({ items }) => items.length > 0) .map(({ key, title, items: sectionItems }, index) => ( diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index bd537a81359..53f0fced6ed 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -1498,6 +1498,8 @@ export async function executeJobInline(payload: JobExecutionPayload) { workspaceId: jobRecord.sourceWorkspaceId, userId: jobRecord.sourceUserId, chatId: jobRecord.sourceChatId || generateId(), + secretScope: jobRecord.secretScope, + mountedSecrets: jobRecord.mountedSecrets, ...(jobRecord.contexts && jobRecord.contexts.length > 0 ? { contexts: jobRecord.contexts } : {}), diff --git a/apps/sim/blocks/blocks/mothership.ts b/apps/sim/blocks/blocks/mothership.ts index 74e81cb07d8..7bf9e17fe7f 100644 --- a/apps/sim/blocks/blocks/mothership.ts +++ b/apps/sim/blocks/blocks/mothership.ts @@ -1,4 +1,5 @@ import { Blimp } from '@sim/emcn' +import { fetchWorkspaceRawSecretNameOptions } from '@/lib/workflows/subblocks/options' import type { BlockConfig } from '@/blocks/types' import type { ToolResponse } from '@/tools/types' @@ -72,6 +73,31 @@ export const MothershipBlock: BlockConfig = { type: 'skill-input', defaultValue: [], }, + { + id: 'secretScope', + title: 'Secret access', + type: 'dropdown', + mode: 'advanced', + hideFromCopilot: true, + options: [ + { label: 'All secrets', id: 'all' }, + { label: 'Selected secrets', id: 'selected' }, + ], + value: () => 'all', + }, + { + id: 'mountedSecrets', + title: 'Secrets', + type: 'dropdown', + mode: 'advanced', + hideFromCopilot: true, + multiSelect: true, + searchable: true, + preserveLabelCase: true, + options: [], + condition: { field: 'secretScope', value: 'selected' }, + fetchOptions: () => fetchWorkspaceRawSecretNameOptions(), + }, ], tools: { access: [], @@ -91,6 +117,8 @@ export const MothershipBlock: BlockConfig = { }, tools: { type: 'json', description: 'MCP tools available to Sim for this request' }, skills: { type: 'json', description: 'Skills activated for this request' }, + secretScope: { type: 'string', description: 'Secret access mode: all or selected' }, + mountedSecrets: { type: 'json', description: 'Secret names available to Sim code execution' }, }, outputs: { content: { type: 'string', description: 'Generated response content' }, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 716625d7102..10b2917c3c1 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -337,6 +337,8 @@ export interface SubBlockConfig { connectionDroppable?: boolean hidden?: boolean hideFromPreview?: boolean // Hide this subblock from the workflow block preview + /** Excludes server-only lifecycle configuration from Copilot workflow state and schemas. */ + hideFromCopilot?: boolean hideDividerBefore?: boolean // Visually group this field with the preceding visible subblock showWhenEnvSet?: string // Show this subblock only when a named NEXT_PUBLIC_ env var is truthy; comma-separated means any of them hideWhenHosted?: boolean // Hide this subblock when running on hosted sim diff --git a/apps/sim/components/page-header-bar.ts b/apps/sim/components/page-header-bar.ts index 1c1fb8e5ce9..23436cad2e2 100644 --- a/apps/sim/components/page-header-bar.ts +++ b/apps/sim/components/page-header-bar.ts @@ -23,3 +23,12 @@ export const TITLE_BAR_LANE_PT = 'pt-[calc(8.5px+var(--workspace-content-title-b * Single source of truth for this geometry — never re-derive it per page. */ export const PAGE_HEADER_BAR = `flex flex-shrink-0 items-center bg-[var(--bg)] px-4 ${TITLE_BAR_LANE_PT} pb-[8.5px]` + +/** + * The right-hand action cluster inside a top bar. Every header — settings, + * credential detail, `Resource` pages, the integrations tab strip — wears this, + * so a chip row is the same height and rhythm wherever it appears. + * + * Single source of truth: never re-derive `h-[30px]`/`gap-1` per header. + */ +export const HEADER_ACTION_CLUSTER = 'flex h-[30px] items-center gap-1' diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 0bf912e7df4..ca1b92fd241 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -191,7 +191,7 @@ describe('settings navigation boundaries', () => { expect(organizationSso?.docsLink).toBe(unifiedSso?.docsLink) }) - it('keeps scope-specific labels only where the surface genuinely differs', () => { + it('uses scope-specific labels consistently across settings surfaces', () => { const organizationMembers = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'members') const unifiedOrganization = buildUnifiedSettingsNavigation().find( ({ id }) => id === 'organization' @@ -199,7 +199,37 @@ describe('settings navigation boundaries', () => { expect(organizationMembers?.label).toBe('Members') expect(organizationMembers?.description).toBe('Manage organization members, roles, and seats.') - expect(unifiedOrganization?.label).toBe('Organization') + expect(unifiedOrganization?.label).toBe('Members') + }) + + it('keeps self-host settings on their standalone account projection', () => { + expect( + SELFHOST_SETTINGS_ITEMS.map(({ id, label, description, group }) => ({ + id, + label, + description, + group, + })) + ).toEqual([ + { + id: 'general', + label: 'General', + description: 'Manage your profile, appearance, and preferences.', + group: 'account', + }, + { + id: 'billing', + label: 'Subscription', + description: 'Manage your personal plan, usage, and invoices.', + group: 'account', + }, + { + id: 'chat-keys', + label: 'Chat keys', + description: 'Manage the model-provider keys that power Chat.', + group: 'developer', + }, + ]) }) it('builds canonical settings hrefs across all three planes', () => { diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 3557160e1d1..1e9a2c79c79 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -120,14 +120,7 @@ export type UnifiedSettingsSection = | 'mothership' | 'recently-deleted' -export type UnifiedNavigationSection = - | 'account' - | 'subscription' - | 'tools' - | 'system' - | 'desktop' - | 'enterprise' - | 'superuser' +export type UnifiedNavigationSection = 'account' | 'workspace' | 'organization' | 'platform' /** * A bridge surface the desktop shell must expose for a section to be worth @@ -142,6 +135,7 @@ export interface UnifiedSettingsNavigationItem { description: string icon: ComponentType<{ className?: string }> section: UnifiedNavigationSection + order: number hideWhenBillingDisabled?: boolean requiresTeam?: boolean requiresEnterprise?: boolean @@ -384,6 +378,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'general', description: 'Manage your profile, appearance, and preferences.', group: 'account', + order: 0, }, planes: { account: { id: 'general', group: 'account', order: 0 }, @@ -396,7 +391,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'desktop', description: 'Manage notifications, startup, local folders, and updates.', - group: 'desktop', + group: 'account', + order: 2, requiresDesktopSurface: 'settings', }, }, @@ -406,7 +402,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'browser', description: 'Control the browser Chat drives and the data it keeps.', - group: 'desktop', + group: 'account', + order: 3, requiresDesktopSurface: 'browser', }, }, @@ -416,18 +413,20 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'terminal', description: 'Control the shells Chat runs commands in.', - group: 'desktop', + group: 'account', + order: 4, requiresDesktopSurface: 'terminal', }, }, { - label: 'Access control', + label: 'Permission groups', icon: ShieldCheck, docsLink: 'https://docs.sim.ai/platform/enterprise/access-control', unified: { id: 'access-control', description: 'Manage permission groups across your organization.', - group: 'enterprise', + group: 'organization', + order: 3, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, @@ -443,7 +442,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'audit-logs', description: 'Review activity and changes across your organization.', - group: 'enterprise', + group: 'organization', + order: 4, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, @@ -453,25 +453,27 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, }, { - label: 'Workspace Forks', + label: 'Workspace forks', icon: Shuffle, docsLink: 'https://docs.sim.ai/platform/enterprise/forks', unified: { id: 'forks', description: 'Fork this workspace and sync changes with its parent.', - group: 'enterprise', + group: 'organization', + order: 2, }, planes: { workspace: { id: 'forks', group: 'enterprise', order: 10 }, }, }, { - label: 'Billing', + label: 'Subscription', icon: ClipboardList, unified: { id: 'billing', description: 'Manage your plan, pricing, and invoices.', - group: 'subscription', + group: 'account', + order: 1, hideWhenBillingDisabled: true, }, planes: { @@ -501,19 +503,21 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'teammates', description: 'Manage your teammates in this workspace.', - group: 'subscription', + group: 'workspace', + order: 0, }, planes: { workspace: { id: 'teammates', group: 'workspace', order: 0 }, }, }, { - label: 'Organization', + label: 'Members', icon: Users, unified: { id: 'organization', description: "Manage your organization's members and seats.", - group: 'subscription', + group: 'organization', + order: 0, hideWhenBillingDisabled: true, requiresHosted: true, requiresTeam: true, @@ -521,7 +525,6 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] planes: { organization: { id: 'members', - label: 'Members', description: 'Manage organization members, roles, and seats.', group: 'organization', order: 0, @@ -534,7 +537,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'secrets', description: 'Store environment variables for your workflows.', - group: 'account', + group: 'workspace', + order: 1, }, planes: { workspace: { id: 'secrets', group: 'workspace', order: 1 }, @@ -546,7 +550,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'custom-tools', description: 'Create and manage custom tools for your agents.', - group: 'tools', + group: 'workspace', + order: 3, }, planes: { workspace: { id: 'custom-tools', group: 'tools', order: 4 }, @@ -557,8 +562,9 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] icon: McpIcon, unified: { id: 'mcp', - description: 'Connect MCP servers and use their tools in workflows.', - group: 'tools', + description: 'Connect external MCP servers and use their tools in this workspace.', + group: 'workspace', + order: 2, }, planes: { workspace: { id: 'mcp', group: 'tools', order: 5 }, @@ -570,7 +576,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'apikeys', description: 'Create and manage API keys for the Sim API.', - group: 'system', + group: 'workspace', + order: 7, }, planes: { account: { @@ -592,8 +599,9 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] icon: Server, unified: { id: 'workflow-mcp-servers', - description: 'Expose your workflows as tools on an MCP server.', - group: 'system', + description: 'Expose workflows from this workspace as tools on an MCP server.', + group: 'workspace', + order: 6, }, planes: { workspace: { id: 'workflow-mcp-servers', group: 'tools', order: 6 }, @@ -605,7 +613,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'byok', description: 'Bring your own model-provider API keys.', - group: 'system', + group: 'workspace', + order: 4, requiresHosted: true, }, planes: { @@ -619,7 +628,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'sandboxes', description: 'Install Python or npm packages for Function blocks to import.', - group: 'system', + group: 'workspace', + order: 8, requiresMax: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sandboxes, showWhenLocked: true, @@ -641,12 +651,13 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, }, { - label: 'Sim mailer', + label: 'Sim Mailer', icon: Send, unified: { id: 'inbox', description: 'Trigger and process workflows from incoming email.', - group: 'system', + group: 'workspace', + order: 5, requiresMax: true, requiresHosted: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.inbox, @@ -662,7 +673,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'recently-deleted', description: 'Restore items deleted in the last 30 days.', - group: 'system', + group: 'workspace', + order: 9, }, planes: { workspace: { id: 'recently-deleted', group: 'system', order: 9 }, @@ -675,7 +687,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'sso', description: 'Configure single sign-on for your organization.', - group: 'enterprise', + group: 'organization', + order: 6, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sso, @@ -691,7 +704,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'sessions', description: 'Limit session lifetimes and sign out members org-wide.', - group: 'enterprise', + group: 'organization', + order: 7, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, @@ -708,7 +722,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'data-retention', description: 'Control data retention windows and PII redaction. Workspaces without an override inherit the organization defaults.', - group: 'enterprise', + group: 'organization', + order: 8, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, @@ -724,7 +739,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'data-drains', description: 'Stream your logs and events to external destinations.', - group: 'enterprise', + group: 'organization', + order: 9, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, @@ -734,13 +750,14 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, }, { - label: 'Whitelabeling', + label: 'White-labeling', icon: Palette, docsLink: 'https://docs.sim.ai/platform/enterprise/whitelabeling', unified: { id: 'whitelabeling', description: 'Customize your workspace branding and appearance.', - group: 'enterprise', + group: 'organization', + order: 5, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, @@ -756,7 +773,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'custom-blocks', description: 'Publish workflows as reusable blocks for your organization.', - group: 'enterprise', + group: 'organization', + order: 1, requiresHosted: true, requiresEnterprise: true, allowNonOrgAdmin: true, @@ -772,7 +790,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'admin', description: 'Superuser administration and workspace tools.', - group: 'superuser', + group: 'platform', + order: 0, requiresAdminRole: true, }, planes: { @@ -785,7 +804,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'mothership', description: 'Internal Sim operations and license management.', - group: 'superuser', + group: 'platform', + order: 1, requiresAdminRole: true, }, planes: { diff --git a/apps/sim/components/settings/settings-header-order.test.ts b/apps/sim/components/settings/settings-header-order.test.ts new file mode 100644 index 00000000000..d2700d7b146 --- /dev/null +++ b/apps/sim/components/settings/settings-header-order.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { saveDiscardActions } from '@/components/settings/save-discard-actions' +import type { SettingsAction } from '@/components/settings/settings-header' +import { orderHeaderActions } from '@/components/settings/settings-header' + +const noop = () => {} + +/** Labels in the order the header renders them. */ +function rendered(actions: SettingsAction[]): string[] { + return orderHeaderActions(actions).map(({ action }) => action.text) +} + +const save = (dirty: boolean) => + saveDiscardActions({ dirty, saving: false, onSave: noop, onDiscard: noop }) + +describe('orderHeaderActions', () => { + it('puts Delete before Discard and Save no matter how the caller ordered them', () => { + // The natural way to write this array — Save/Discard first, then Delete — + // is what every settings detail page did, and it rendered Delete to the + // right of the primary chip. + const actions: SettingsAction[] = [ + ...save(true), + { id: 'delete', text: 'Delete', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Delete', 'Discard', 'Save']) + }) + + it('matches the skills detail header: secondary actions, then Delete, then Save', () => { + const actions: SettingsAction[] = [ + { text: 'Share', onSelect: noop }, + { id: 'delete', text: 'Delete', onSelect: noop }, + ...save(true), + ] + + expect(rendered(actions)).toEqual(['Share', 'Delete', 'Discard', 'Save']) + }) + + it('keeps Save right-most when there is nothing to discard', () => { + const actions: SettingsAction[] = [ + ...save(false), + { id: 'delete', text: 'Delete', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Delete', 'Save']) + }) + + it('sends any primary action to the end, not just Save', () => { + // Workflow MCP servers: Add workflows is the primary, Delete must precede it. + const actions: SettingsAction[] = [ + { text: 'Edit server', onSelect: noop }, + { text: 'Add workflows', variant: 'primary', onSelect: noop }, + { id: 'delete', text: 'Delete', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Edit server', 'Delete', 'Add workflows']) + }) + + it('places Delete by its id, not by where the caller listed it', () => { + // A page with no primary action still must not leave Delete in the slot a + // primary would occupy — files detail is exactly this shape. + const actions: SettingsAction[] = [ + { id: 'delete', text: 'Delete', onSelect: noop }, + { text: 'Download', onSelect: noop }, + { text: 'Share', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Download', 'Share', 'Delete']) + }) + + it('preserves caller order within a band', () => { + const actions: SettingsAction[] = [ + { text: 'Refresh', onSelect: noop }, + { text: 'Edit', onSelect: noop }, + { id: 'delete', text: 'Delete', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Refresh', 'Edit', 'Delete']) + }) + + it('leaves a destructive bulk action left of the primary', () => { + // Passwords: `Delete all` is destructive but must not outrank `Import`. + // This is what keeps a red chip from becoming the right-most control. + const actions: SettingsAction[] = [ + { text: 'Delete all', variant: 'destructive', onSelect: noop }, + { text: 'Import', variant: 'primary', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Delete all', 'Import']) + }) + + it('ranks a destructive action alongside secondary ones, not after Discard', () => { + const actions: SettingsAction[] = [ + ...save(true), + { text: 'Sign out all members', variant: 'destructive', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Sign out all members', 'Discard', 'Save']) + }) + + it('treats primary as the stronger signal when an action is both', () => { + const actions: SettingsAction[] = [ + { text: 'Other', onSelect: noop }, + { id: 'discard', text: 'Odd', variant: 'primary', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Other', 'Odd']) + }) + + it('carries each action original index so ref-routed handlers stay bound', () => { + const actions: SettingsAction[] = [ + ...save(true), // indices 0 (Discard), 1 (Save) + { id: 'delete', text: 'Delete', onSelect: noop }, // index 2 + ] + + expect(orderHeaderActions(actions).map(({ action, index }) => [action.text, index])).toEqual([ + ['Delete', 2], + ['Discard', 0], + ['Save', 1], + ]) + }) + + it('tolerates an absent or empty action list', () => { + expect(orderHeaderActions(undefined)).toEqual([]) + expect(orderHeaderActions([])).toEqual([]) + }) + + it('does not mutate the caller array', () => { + // The shell sorts a prop read off a live ref; reordering it in place would + // renumber the indices the handlers are routed through. + const actions: SettingsAction[] = [ + ...save(true), + { id: 'delete', text: 'Delete', onSelect: noop }, + ] + const before = actions.map((a) => a.text) + + orderHeaderActions(actions) + + expect(actions.map((a) => a.text)).toEqual(before) + }) +}) diff --git a/apps/sim/components/settings/settings-header-shell.test.tsx b/apps/sim/components/settings/settings-header-shell.test.tsx new file mode 100644 index 00000000000..bca2da37694 --- /dev/null +++ b/apps/sim/components/settings/settings-header-shell.test.tsx @@ -0,0 +1,116 @@ +/** + * @vitest-environment jsdom + * + * The shell renders header actions in ranked order but routes every handler + * through `configRef.current.actions[index]` to dodge stale closures. Those two + * facts fight each other: if the reordered render ever renumbered the indices, + * clicking Delete would invoke Save. These tests pin the pairing at the render + * level, which the pure-function tests cannot reach. + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { saveDiscardActions } from '@/components/settings/save-discard-actions' +import type { SettingsAction } from '@/components/settings/settings-header' +import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' +import { SettingsPanel } from '@/components/settings/settings-panel' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +function renderHeader(actions: SettingsAction[]) { + act(() => { + root.render( + + + +
+ + + + ) + }) +} + +/** Header chips in rendered (left→right) order. */ +function chipLabels(): string[] { + return [...container.querySelectorAll('header button, div button')] + .map((node) => node.textContent?.trim() ?? '') + .filter(Boolean) +} + +function clickChip(label: string) { + const chip = [...container.querySelectorAll('button')].find( + (node) => node.textContent?.trim() === label + ) + if (!chip) throw new Error(`no chip labelled "${label}" (have: ${chipLabels().join(', ')})`) + act(() => { + chip.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +describe('SettingsHeaderShell action routing', () => { + it('renders Delete before Discard and Save even though the array lists it last', () => { + const actions: SettingsAction[] = [ + ...saveDiscardActions({ dirty: true, saving: false, onSave: vi.fn(), onDiscard: vi.fn() }), + { id: 'delete', text: 'Delete', onSelect: vi.fn() }, + ] + + renderHeader(actions) + + const labels = chipLabels() + expect(labels.indexOf('Delete')).toBeLessThan(labels.indexOf('Discard')) + expect(labels.indexOf('Discard')).toBeLessThan(labels.indexOf('Save')) + }) + + it('invokes the action that was clicked, not the one at that render position', () => { + const onSave = vi.fn() + const onDiscard = vi.fn() + const onDelete = vi.fn() + + renderHeader([ + ...saveDiscardActions({ dirty: true, saving: false, onSave, onDiscard }), + { id: 'delete', text: 'Delete', onSelect: onDelete }, + ]) + + // Delete renders first but lives at source index 2. + clickChip('Delete') + expect(onDelete).toHaveBeenCalledTimes(1) + expect(onSave).not.toHaveBeenCalled() + expect(onDiscard).not.toHaveBeenCalled() + + clickChip('Save') + expect(onSave).toHaveBeenCalledTimes(1) + expect(onDelete).toHaveBeenCalledTimes(1) + }) + + it('stays correctly bound when a conditional action shifts every index', () => { + // Sandboxes: Discard only exists while dirty, so Delete moves 2 -> 1. + const onSave = vi.fn() + const onDelete = vi.fn() + + renderHeader([ + ...saveDiscardActions({ dirty: false, saving: false, onSave, onDiscard: vi.fn() }), + { id: 'delete', text: 'Delete', onSelect: onDelete }, + ]) + + clickChip('Delete') + + expect(onDelete).toHaveBeenCalledTimes(1) + expect(onSave).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/components/settings/settings-header.tsx b/apps/sim/components/settings/settings-header.tsx index 9093b4d70b4..91747443095 100644 --- a/apps/sim/components/settings/settings-header.tsx +++ b/apps/sim/components/settings/settings-header.tsx @@ -14,7 +14,7 @@ import { useState, } from 'react' import { Chip, ChipInput, ChipLink, cn, Search, Tooltip } from '@sim/emcn' -import { PAGE_HEADER_BAR } from '@/components/page-header-bar' +import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect @@ -73,6 +73,9 @@ function computeSignature(config: SettingsHeaderConfig): string { back: config.back ? [config.back.text, config.back.icon ? 1 : 0] : null, actions: config.actions?.map((action) => [ action.text, + // `id` participates in ordering, so a config that changes only the id + // must still re-render — the sort key cannot be wider than the signature. + action.id ?? '', action.textTone ?? '', action.variant ?? '', action.active ?? false, @@ -180,13 +183,45 @@ export function SettingsActionChip({ export function SettingsActionChips({ actions }: { actions: SettingsAction[] }) { return ( <> - {actions.map((action) => ( + {orderHeaderActions(actions).map(({ action }) => ( ))} ) } +/** + * Every header reads left→right as + * `[secondary actions] → [Delete] → [Discard] → [Save]`. + * + * Delete is placed by its `id`, not by where the caller happened to put it, so a + * page with no primary action still can't leave a destructive chip in the slot a + * primary would occupy. + * + * The shell enforces it rather than trusting callsites, because the natural way + * to write the array — spreading {@link saveDiscardActions} first, then adding a + * Delete — produces the opposite order and puts a destructive chip to the right + * of the primary one. Ranking is stable, so an action's position within its own + * band is still the caller's to choose. + * + * Pairs each action with its ORIGINAL index: the shell dereferences + * `actions[index]` on a live ref to dodge stale closures, so a reordered render + * must not renumber them. + */ +export function orderHeaderActions( + actions: SettingsAction[] | undefined +): { action: SettingsAction; index: number }[] { + const rank = (action: SettingsAction) => { + if (action.variant === 'primary') return 3 + if (action.id === 'discard') return 2 + if (action.id === 'delete') return 1 + return 0 + } + return (actions ?? []) + .map((action, index) => ({ action, index })) + .sort((a, b) => rank(a.action) - rank(b.action)) +} + export function SettingsHeaderShell({ children }: { children: ReactNode }) { const read = useContext(ReadContext) const configRef = read?.configRef @@ -203,13 +238,13 @@ export function SettingsHeaderShell({ children }: { children: ReactNode }) { ) : (
)} -
+
{docsLink && ( Docs )} - {actions?.map((action, index) => ( + {orderHeaderActions(actions).map(({ action, index }) => ( { @@ -32,4 +33,14 @@ describe('standalone settings section resolution', () => { }) ).toBe('audit-logs') }) + + it('keeps Subscription active for the self-host billing route', () => { + expect( + parseSettingsPathSection({ + path: '/selfhost/settings/billing', + items: SELFHOST_SETTINGS_ITEMS, + defaultSection: 'general', + }) + ).toBe('billing') + }) }) diff --git a/apps/sim/content/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/index.mdx b/apps/sim/content/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/index.mdx new file mode 100644 index 00000000000..a692fb2422f --- /dev/null +++ b/apps/sim/content/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/index.mdx @@ -0,0 +1,94 @@ +--- +slug: aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean +title: 'AEO vs GEO: What Answer Engine and Generative Engine Optimization Actually Mean' +description: 'Understand AEO vs GEO, how answer engine and generative engine optimization differ from traditional SEO, and the content practices that make answers easier to cite.' +date: 2026-08-03 +updated: 2026-08-03 +authors: + - andrew +readingTime: 6 +tags: [SEO, Generative AI, Content Strategy, Sim] +ogImage: /library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/cover.jpg +canonical: https://www.sim.ai/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean +draft: false +faq: + - q: "What is the difference between AEO and GEO?" + a: "AEO emerged around direct-answer surfaces such as featured snippets, while GEO focuses on being selected and cited in synthesized generative answers. In practice, both require clear, self-contained, verifiable content, so their tactics substantially overlap." + - q: "Is GEO replacing SEO?" + a: "No. SEO still helps people and search systems discover pages. GEO adds an emphasis on writing passages that can be quoted, verified, and used as evidence in a generated answer." + - q: "What content is most useful for answer engines?" + a: "Content that answers a specific question early, defines its terms, gives concrete evidence, and clearly states tradeoffs is easier for both readers and answer systems to use." + - q: "Do comparison tables help with AEO and GEO?" + a: "A comparison table can make distinctions between options explicit. It works best when its rows use clear criteria and its surrounding text explains the practical tradeoffs." +--- + +## TL;DR + +- **AEO (Answer Engine Optimization)** means structuring content so a machine can lift it whole into a direct answer, from [featured snippets](https://developers.google.com/search/docs/appearance/featured-snippets) to AI chat replies. +- **GEO (Generative Engine Optimization)** means optimizing content so generative systems select and cite it when they synthesize an answer. +- The terms now overlap heavily, and the label matters less than the mechanics both require. +- The comparison below shows how traditional SEO, AEO, and GEO differ across focus, surface, and tactics. +- The mechanics section covers the concrete moves that make content easier to quote and verify, whichever label you use. + +## What is AEO (Answer Engine Optimization)? + +Answer Engine Optimization (AEO) is the practice of structuring content so a machine can lift a complete answer out of it and present that answer directly to a user. The term grew out of the answer-box era: [Google describes featured snippets as excerpts from web pages that it automatically determines can answer a searcher's question](https://developers.google.com/search/docs/appearance/featured-snippets). + +That origin shaped the core mechanic. To win a snippet or a voice-style answer, a passage needs to make sense on its own, answer a specific question in its first sentence or two, and need no surrounding context to be understood. A page that buries its answer three paragraphs down is less useful than a page that leads with it. + +AEO can also describe AI chat surfaces, but the mechanic does not change with the surface. The goal is still to write an answer a machine can quote without editing. The vocabulary expanded to include AI answers; the discipline stayed focused on self-contained, question-first structure. + +## What is GEO (Generative Engine Optimization)? + +GEO (Generative Engine Optimization) is the practice of structuring content so generative AI systems select and cite it when they synthesize an answer. The term comes from the 2023 paper by researchers at Princeton, Georgia Tech, and the Allen Institute for AI, ["GEO: Generative Engine Optimization"](https://arxiv.org/abs/2311.09735), which examined how content changes affect visibility in generative-engine responses. + +The paper framed visibility and citation rate inside generative outputs as useful measures: how often and how prominently a source appears in an answer a model produces. That is a newer framing than AEO. AEO grew around direct-answer surfaces; GEO starts from a different question: when a model composes an answer from many sources, what makes it choose yours? + +GEO targets surfaces where an AI reads across documents and writes a synthesized response rather than lifting one boxed answer. [ChatGPT Search presents answers with source citations](https://help.openai.com/en/articles/9237897-chatgpt-search), [Perplexity explains how its citations support answer claims](https://www.perplexity.ai/help-center/en/articles/10352895-what-are-citations), [Gemini is Google's generative AI assistant](https://gemini.google.com/), and [Google AI Overviews link to supporting web results](https://blog.google/products/search/generative-ai-search/). Winning in these surfaces means becoming one of the useful inputs to a response, not only pursuing a top-ranked document. + +## AEO vs GEO: where they actually diverge + +The difference between AEO and GEO is emphasis and origin, not a separate set of writing mechanics. AEO grew out of direct-answer results, where success meant supplying a concise response. GEO grew out of research into how generative models select sources, where success means appearing alongside other pages in a synthesized answer. Both describe the task of writing content a machine can lift and reuse. + +The single-answer framing is also less clean than it once was. [Google's description of AI Overviews](https://blog.google/products/search/generative-ai-search/) presents them as AI-generated overviews with links to explore supporting information, blending direct answers with cited sources. When one surface combines both patterns, drawing a hard line between the two disciplines becomes less useful. + +Arguing over which label is correct matters less than optimizing for machine extraction. Whether you call it AEO or GEO, answer the question early, write sections that stand alone when quoted, and support concrete specifics with sources a reader can check. + +## Traditional SEO vs AEO vs GEO + +The three approaches emphasize different output surfaces, even though their content mechanics overlap in practice. + +| Dimension | Traditional SEO | AEO | GEO | +| --- | --- | --- | --- | +| Primary focus | Making a page discoverable in ranked results, including the [ranking systems Google documents](https://developers.google.com/search/docs/fundamentals/ranking-systems) | Providing a concise, direct response that can be surfaced on its own | Supplying clear, verifiable material that can support a synthesized answer | +| Output surface | Ranked search results | Featured snippets and direct-answer experiences | Cited passages in generative answers | +| Useful tactics | Helpful information architecture, internal linking, and technical accessibility | Question-based headings, concise lead answers, and [structured data where it applies](https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data) | First-sentence answers, self-contained sections, sourced specifics, and honest tradeoffs | + +The row that matters most is tactics, and the overlap there is the point. Both AEO and GEO benefit from answering the question early and structuring content so a machine can lift it cleanly. Traditional SEO remains important for discovery, but it does not replace the need for passages that are understandable when quoted on their own. + +## The mechanics that make content easier to cite, regardless of label + +Six practices make material easier to extract, quote, and verify. None depends on whether you call the work AEO or GEO. + +1. **Answer the question in the first one or two sentences of a section.** A lead that opens with backstory makes a reader or system hunt for the point. Put the direct answer first, then supply the reasoning and context. +2. **Write sections that stand alone when quoted.** A paragraph can be separated from the surrounding page in a summary or answer. Give each section enough context to make sense without the sentence before it. +3. **Use concrete, sourced specifics instead of adjectives.** A verifiable number, date, or named source gives a reader something to check. For example, "reduced latency by 40 percent" is more informative than "blazing fast" only when the measurement and its source are available. +4. **Build comparison tables where the content compares options.** A table makes the relationship between items explicit. Use clear criteria in its rows, and use the surrounding prose to explain what the comparison means in practice. +5. **Define terms on first use.** Defining an acronym in context gives readers and systems a complete answer to a definitional question rather than forcing them to infer the meaning from nearby text. +6. **Admit tradeoffs and drop promotional framing.** A useful source explains where an approach works well and where it does not. Writing "this works well for X but struggles with Y" gives readers a balanced comparison they can evaluate instead of an unsupported promise. + +For teams building AI-powered workflows, the same discipline also improves the material an agent works from. [What is an AI agent?](https://www.sim.ai/library/what-is-an-ai-agent-definition-how-it-works-and-examples) explains how agents use models, tools, memory, and goals; clear source material helps those systems and their users assess an answer. + +## How Sim applies this in practice + +For a team using Sim, AEO and GEO do not need separate checklists. Start with the editorial work: make the target question explicit, state the answer before its lead-up, define terms, and connect important claims to the evidence behind them. Those choices make an article more useful to a person reading it and more portable when a system needs a focused passage. + +The same approach is useful when documenting an AI workflow. A guide to [building AI agents with Sim](https://www.sim.ai/library/how-to-create-an-ai-agent) can state the outcome and constraints before its implementation detail, while an [AI agent observability](https://www.sim.ai/library/ai-agent-observability) plan can record the evidence needed to evaluate an answer or decision. Neither example requires choosing an AEO label over a GEO label first. + +Question-based H2s can mirror the language people use when they ask an assistant for help. Comparison tables can clarify choices. Honest tradeoffs can prevent a generated summary from turning a conditional recommendation into a blanket claim. These are practical writing decisions, not competing optimization programs. + +## The bottom line + +AEO and GEO describe closely related work: writing content so machines can extract, reuse, and, where the surface supports it, cite it rather than only rank it. The label you pick changes little about the work. Answer the question in the first sentence, write sections that hold up when quoted alone, support concrete specifics with sources, and admit tradeoffs. + +Do that and your content is more useful in direct-answer and generative-search experiences. Spend the effort on the mechanics, not the terminology. diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index ce4330347b1..003ba3eb514 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -14,7 +14,7 @@ import { } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { ArrowRight, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { isEnterprise } from '@/lib/billing/plan-helpers' @@ -31,6 +31,10 @@ import { } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { GroupDetail } from '@/ee/access-control/components/group-detail' @@ -252,37 +256,27 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon No groups found matching "{searchTerm}" ) : ( -
+
{filteredGroups.map((group) => ( - + clickLabel={`Open ${group.name}`} + navigable + /> ))}
)} diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 5e47ebee4d2..12ace16b1ad 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -436,9 +436,9 @@ function AddMembersModal({
{filteredMembers.length === 0 ? ( -

+ No members found matching "{searchTerm}" -

+ ) : (
{filteredMembers.map((member) => { @@ -451,7 +451,7 @@ function AddMembersModal({ key={member.userId} type='button' onClick={() => handleToggleMember(member.userId)} - className='flex items-center gap-2.5 rounded-sm p-2 text-left hover-hover:bg-[var(--surface-active)]' + className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' > @@ -1543,8 +1543,8 @@ export function GroupDetail({ saveDisabled: !trimmedName, }), { + id: 'delete', text: deletePermissionGroup.isPending ? 'Deleting...' : 'Delete', - variant: 'destructive', onSelect: () => setShowDeleteConfirm(true), disabled: deletePermissionGroup.isPending, }, diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx index 94bd6bc3b73..d4d22822858 100644 --- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx @@ -439,8 +439,8 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD ...(existing && canManageBlock ? [ { + id: 'delete', text: remove.isPending ? 'Deleting...' : 'Delete', - variant: 'destructive' as const, onSelect: () => { setShowDelete(true) // The warning must reflect the org's CURRENT usage, not a diff --git a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx index 50d0b7ebceb..428b433da5f 100644 --- a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react' import { ChipTag } from '@sim/emcn' -import { ArrowRight, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' @@ -13,7 +13,10 @@ import { } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon' @@ -126,30 +129,21 @@ export function CustomBlocks() { No blocks found matching "{searchTerm}" ) : ( -
+
{filtered.map((cb) => { const Icon = getCustomBlockIcon(cb.iconUrl, fallbackIconUrl) return ( - + icon={} + iconFill + title={cb.name} + description={cb.description || undefined} + onClick={canAdmin ? () => void setSelectedBlockId(cb.id) : undefined} + clickLabel={`Open ${cb.name}`} + navigable={canAdmin} + badge={!cb.enabled ? Disabled : undefined} + /> ) })}
diff --git a/apps/sim/ee/data-drains/components/data-drain-detail.tsx b/apps/sim/ee/data-drains/components/data-drain-detail.tsx index dc7c65c291d..e8d5bcf68e0 100644 --- a/apps/sim/ee/data-drains/components/data-drain-detail.tsx +++ b/apps/sim/ee/data-drains/components/data-drain-detail.tsx @@ -150,8 +150,8 @@ export function DataDrainDetail({ organizationId, drain, onBack }: DataDrainDeta }, { text: 'Test connection', onSelect: handleTest, disabled: testDrain.isPending }, { + id: 'delete', text: 'Delete', - variant: 'destructive', onSelect: () => setShowDeleteConfirm(true), disabled: deleteDrain.isPending, }, diff --git a/apps/sim/ee/data-drains/components/data-drains-settings.tsx b/apps/sim/ee/data-drains/components/data-drains-settings.tsx index 659c758ffb3..9a0235acdf3 100644 --- a/apps/sim/ee/data-drains/components/data-drains-settings.tsx +++ b/apps/sim/ee/data-drains/components/data-drains-settings.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { ChipTag } from '@sim/emcn' -import { ArrowRight, Database, Plus } from '@sim/emcn/icons' +import { Database, Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useQueryState } from 'nuqs' import { @@ -12,7 +12,10 @@ import { import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { DataDrainCreate } from '@/ee/data-drains/components/data-drain-create' import { DataDrainDetail } from '@/ee/data-drains/components/data-drain-detail' @@ -105,42 +108,32 @@ export function DataDrainsSettings({ organizationId }: DataDrainsSettingsProps) }} > {error ? ( -
-

- {getErrorMessage(error, "Couldn't load data drains")} -

-
+ + {getErrorMessage(error, "Couldn't load data drains")} + ) : isPending ? null : drains && drains.length > 0 ? ( -
+
{filteredDrains.map((drain) => ( - + clickLabel={`Open ${drain.name}`} + navigable + badge={!drain.enabled ? Disabled : undefined} + /> ))} {filteredDrains.length === 0 && ( diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index 8359777bd59..81e49ceb12e 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -18,7 +18,7 @@ import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { ArrowRight, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { CustomPatternsEditor } from '@/components/pii/custom-patterns-editor' import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { SettingsAction } from '@/components/settings/settings-header' @@ -45,6 +45,10 @@ import { import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { @@ -512,8 +516,8 @@ function PolicyDetail({ ...(canRemove ? [ { + id: 'delete', text: 'Remove override', - variant: 'destructive', onSelect: () => setShowRemoveConfirm(true), disabled: isSaving, } satisfies SettingsAction, @@ -994,42 +998,24 @@ export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSe ]} > -
- + clickLabel='Open organization retention policy' + navigable + /> {overrideWorkspaceIds.map((workspaceId) => ( - + clickLabel={`Open ${workspaceName(workspaceId)} retention override`} + navigable + /> ))}
diff --git a/apps/sim/ee/sso/components/sso-auth.tsx b/apps/sim/ee/sso/components/sso-auth.tsx index c4e8ac53fe1..0af36c8de01 100644 --- a/apps/sim/ee/sso/components/sso-auth.tsx +++ b/apps/sim/ee/sso/components/sso-auth.tsx @@ -137,7 +137,7 @@ export default function SSOAuth({ identifier }: SSOAuthProps) { )} /> {showEmailValidationError && emailErrors.length > 0 && ( -
+
{emailErrors.map((error) => (

{error}

))} diff --git a/apps/sim/ee/sso/components/sso-form.tsx b/apps/sim/ee/sso/components/sso-form.tsx index 638a004e216..1c41e429e3e 100644 --- a/apps/sim/ee/sso/components/sso-form.tsx +++ b/apps/sim/ee/sso/components/sso-form.tsx @@ -170,7 +170,7 @@ export default function SSOForm() { )} /> {showEmailValidationError && emailErrors.length > 0 && ( -
+
{emailErrors.map((error) => (

{error}

))} diff --git a/apps/sim/ee/sso/components/verified-domains-section.tsx b/apps/sim/ee/sso/components/verified-domains-section.tsx index e069fd16d53..bf914a9e005 100644 --- a/apps/sim/ee/sso/components/verified-domains-section.tsx +++ b/apps/sim/ee/sso/components/verified-domains-section.tsx @@ -46,16 +46,16 @@ function DomainRow({ organizationId, domain, onRemove }: DomainRowProps) { icon={} title={domain.domain} description={isVerified ? 'Ownership verified' : 'Awaiting DNS verification'} + badge={ + + {isVerified ? 'Verified' : 'Pending'} + + } trailing={ -
- - {isVerified ? 'Verified' : 'Pending'} - - onRemove(domain), destructive: true }]} - /> -
+ onRemove(domain), destructive: true }]} + /> } /> diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index e7d7a7d6131..02e22593e73 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -12,7 +12,7 @@ import { Label, Tooltip, } from '@sim/emcn' -import { ArrowRight } from 'lucide-react' +import { ArrowRight } from '@sim/emcn/icons' import type { ForkCopyableUnmapped, ForkDependentReconfig, diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index e267183e144..d37e71b8887 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -440,6 +440,72 @@ describe('BlockExecutor', () => { expect(output).not.toEqual({ content: '' }) }) + it('keeps Sim Chat secret policy in runtime inputs and out of trace inputs', async () => { + const block = createBlock() + block.id = 'mothership-block-1' + block.metadata = { id: BlockType.MOTHERSHIP, name: 'Sim Chat' } + block.config = { + tool: BlockType.MOTHERSHIP, + params: { + prompt: 'Run the task', + secretScope: 'selected', + mountedSecrets: ['OPENAI_API_KEY'], + }, + } + block.privateInputIds = ['secretScope', 'mountedSecrets'] + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + const resolver = new VariableResolver(workflow, {}, state) + const handler: BlockHandler = { + canHandle: () => true, + execute: async (_ctx, _block, inputs) => { + expect(inputs).toMatchObject({ + prompt: 'Run the task', + secretScope: 'selected', + mountedSecrets: ['OPENAI_API_KEY'], + }) + return { content: 'done' } + }, + } + const executor = new BlockExecutor( + [handler], + resolver, + { + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + metadata: { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + triggerType: 'manual', + useDraftState: false, + startTime: new Date().toISOString(), + }, + }, + state + ) + const ctx = createContext(state) + + await executor.execute(ctx, createNode(block), block) + + expect(ctx.blockLogs[0]?.input).toEqual({ prompt: 'Run the task' }) + const { traceSpans } = buildTraceSpans({ + success: true, + output: { content: 'done' }, + logs: ctx.blockLogs, + }) + expect(traceSpans[0]?.input).toEqual({ prompt: 'Run the task' }) + }) + it('projects a resolved secret out of Function syntax-error TraceSpans only', async () => { const secret = 'function-secret-literal-7f3a91' const block = createBlock() diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 105eb517863..b4f90fcc1c7 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -160,7 +160,7 @@ export class BlockExecutor { } if (blockLog) { - blockLog.input = this.sanitizeInputsForLog(inputsForLog, block.metadata?.id) + blockLog.input = this.sanitizeInputsForLog(inputsForLog, block) } } catch (error) { cleanupSelfReference?.() @@ -300,7 +300,7 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(inputsForLog, block.metadata?.id), + this.sanitizeInputsForLog(inputsForLog, block), displayOutput, duration, blockLog.startedAt, @@ -413,7 +413,7 @@ export class BlockExecutor { blockLog.durationMs = duration blockLog.success = true blockLog.error = undefined - blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id) + blockLog.input = this.sanitizeInputsForLog(input, block) blockLog.output = filterOutputForLog(block.metadata?.id || '', softOutput, { block }) } @@ -428,7 +428,7 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(input, block.metadata?.id), + this.sanitizeInputsForLog(input, block), filterOutputForLog(block.metadata?.id || '', softOutput, { block }), duration, blockLog.startedAt, @@ -480,7 +480,7 @@ export class BlockExecutor { blockLog.durationMs = duration blockLog.success = false blockLog.error = errorMessage - blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id) + blockLog.input = this.sanitizeInputsForLog(input, block) blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceSpans.length > 0) { @@ -507,7 +507,7 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(input, block.metadata?.id), + this.sanitizeInputsForLog(input, block), displayOutput, duration, blockLog.startedAt, @@ -631,8 +631,10 @@ export class BlockExecutor { */ private sanitizeInputsForLog( inputs: Record, - blockType?: string + block?: SerializedBlock ): Record { + const blockType = block?.metadata?.id + const privateInputIds = new Set(block?.privateInputIds ?? []) // Custom (deploy-as-block) blocks run via an internal `workflow_executor`; the // baked `workflowId`/`inputMapping` wrapper is plumbing. Log the mapped input // field values (the inputMapping contents) instead. @@ -658,7 +660,8 @@ export class BlockExecutor { SYSTEM_SUBBLOCK_IDS.includes(key) || key === 'triggerMode' || key === FUNCTION_BLOCK_CONTEXT_VARS_KEY || - key === FUNCTION_BLOCK_DISPLAY_CODE_KEY + key === FUNCTION_BLOCK_DISPLAY_CODE_KEY || + privateInputIds.has(key) ) { continue } diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index c70776aba80..63d4750ba5f 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -377,6 +377,8 @@ describe('MothershipBlockHandler', () => { chatId: 'chat-uuid', messageId: 'message-uuid', requestId: 'request-uuid', + secretScope: 'all', + mountedSecrets: [], workflowId: 'workflow-1', executionId: 'execution-1', }) @@ -443,6 +445,8 @@ describe('MothershipBlockHandler', () => { chatId: 'existing-chat-id', messageId: 'message-uuid', requestId: 'request-uuid', + secretScope: 'all', + mountedSecrets: [], workflowId: 'workflow-1', executionId: 'execution-1', }) diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index b12dea62f66..cbc288f6e44 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -5,6 +5,7 @@ import { BILLING_ATTRIBUTION_HEADER, serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { env } from '@/lib/core/config/env' import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation' import { readUserFileContent } from '@/lib/execution/payloads/materialization.server' @@ -397,6 +398,10 @@ export class MothershipBlockHandler implements BlockHandler { const chatId = providedConversationId || generateId() const messageId = generateId() const requestId = generateId() + const secretMountPolicy = normalizeSecretMountPolicy({ + secretScope: inputs.secretScope, + mountedSecrets: inputs.mountedSecrets, + }) const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId) const mcpTools = Array.isArray(inputs.tools) ? inputs.tools.filter( @@ -442,6 +447,8 @@ export class MothershipBlockHandler implements BlockHandler { chatId, messageId, requestId, + secretScope: secretMountPolicy.secretScope, + mountedSecrets: secretMountPolicy.mountedSecrets, ...(fileAttachments && { fileAttachments }), ...(mcpTools.length > 0 ? { mcpTools } : {}), ...(skillContexts.length > 0 ? { contexts: skillContexts } : {}), diff --git a/apps/sim/executor/utils/code-secret-references.test.ts b/apps/sim/executor/utils/code-secret-references.test.ts new file mode 100644 index 00000000000..4ebf81a0ebe --- /dev/null +++ b/apps/sim/executor/utils/code-secret-references.test.ts @@ -0,0 +1,37 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' + +describe('Copilot code secret declarations', () => { + it.each(['javascript', 'python'])( + 'matches trimmed and embedded references for %s', + (language) => { + expect( + extractCodeSecretNames( + 'const first = "prefix-{{ API_KEY }}"\nreturn "{{TOKEN}}/{{API_KEY}}"', + language + ) + ).toEqual(['API_KEY', 'TOKEN']) + } + ) + + it('matches only runtime-valid shell identifiers without trimming', () => { + expect( + extractCodeSecretNames( + 'echo {{API_KEY}} {{ API_KEY }} {{9INVALID}} {{WITH-DASH}} {{_TOKEN}}', + 'shell' + ) + ).toEqual(['API_KEY', '_TOKEN']) + }) + + it('ignores direct environment access, shell variables, literals, and malformed references', () => { + expect( + extractCodeSecretNames( + 'return environmentVariables.API_KEY + "$TOKEN" + "literal" + "{{}}" + "{{MISSING"', + 'javascript' + ) + ).toEqual([]) + }) +}) diff --git a/apps/sim/executor/utils/code-secret-references.ts b/apps/sim/executor/utils/code-secret-references.ts new file mode 100644 index 00000000000..3e3bfc19edc --- /dev/null +++ b/apps/sim/executor/utils/code-secret-references.ts @@ -0,0 +1,38 @@ +import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' + +function resolveCodeLanguage(language: unknown): CodeLanguage { + return typeof language === 'string' && isValidCodeLanguage(language) + ? language + : DEFAULT_CODE_LANGUAGE +} + +export function createCodeEnvVarPattern(language?: unknown): RegExp { + return resolveCodeLanguage(language) === CodeLanguage.Shell + ? /\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g + : createEnvVarPattern() +} + +/** + * Extracts only environment references the Function runtime can resolve for the selected language. + * The returned order follows the code, with duplicate names removed after their first occurrence. + */ +export function extractCodeSecretNames(code: unknown, language?: unknown): string[] { + if (typeof code !== 'string') return [] + + const resolvedLanguage = resolveCodeLanguage(language) + const pattern = createCodeEnvVarPattern(resolvedLanguage) + const names: string[] = [] + const seen = new Set() + let match: RegExpExecArray | null + + while ((match = pattern.exec(code)) !== null) { + const name = resolvedLanguage === CodeLanguage.Shell ? match[1] : match[1].trim() + if (name.length > 0 && !seen.has(name)) { + seen.add(name) + names.push(name) + } + } + + return names +} diff --git a/apps/sim/executor/utils/resolved-secret-content-projection.ts b/apps/sim/executor/utils/resolved-secret-content-projection.ts new file mode 100644 index 00000000000..bb04d6e98f2 --- /dev/null +++ b/apps/sim/executor/utils/resolved-secret-content-projection.ts @@ -0,0 +1,427 @@ +import { isPlainRecord } from '@sim/utils/object' +import { LARGE_ARRAY_MANIFEST_MARKER } from '@/lib/execution/payloads/large-array-manifest-metadata' +import { LARGE_VALUE_REF_MARKER } from '@/lib/execution/payloads/large-value-ref' +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/materialization.server' +import type { ResolvedSecretTraceMatch } from '@/executor/utils/resolved-secret-trace-registry' + +const MAX_CONTENT_NODES = 100_000 +const MAX_CONTENT_DEPTH = 100 +const MAX_MATCHER_NODES = 250_000 +const MAX_SECRET_LITERAL_LENGTH = 64 * 1024 +const MAX_MATCH_EVENTS = 1_000_000 + +interface SecretReplacement { + plaintext: string + replacement: string +} + +interface SecretTrieNode { + children: Map + failure?: SecretTrieNode + outputLink?: SecretTrieNode + replacement?: SecretReplacement +} + +export interface ResolvedSecretMatcher { + root: SecretTrieNode + maxPatternLength: number +} + +interface ProjectionState { + nodes: number + ancestors: WeakSet + outputBytes: number + maxBytes: number +} + +export interface ResolvedSecretContentProjectionOptions { + /** Values already materialized and verified by a boundary-specific projector. */ + isOpaqueSafeObject?: (value: object) => boolean +} + +export type ResolvedSecretContentProjection = { safe: true; value: unknown } | { safe: false } + +class ResolvedSecretContentProjectionError extends Error { + constructor(message: string) { + super(message) + this.name = 'ResolvedSecretContentProjectionError' + } +} + +function compareStrings(left: string, right: string): number { + if (left < right) return -1 + if (left > right) return 1 + return 0 +} + +function createMatcherFromReplacements( + replacements: readonly SecretReplacement[] +): ResolvedSecretMatcher { + const root: SecretTrieNode = { children: new Map() } + root.failure = root + let nodeCount = 1 + let maxPatternLength = 0 + + for (const replacement of replacements) { + if (replacement.plaintext.length > MAX_SECRET_LITERAL_LENGTH) { + throw new ResolvedSecretContentProjectionError( + 'Secret literal exceeds the matcher size limit' + ) + } + maxPatternLength = Math.max(maxPatternLength, replacement.plaintext.length) + let node = root + for (let index = 0; index < replacement.plaintext.length; index += 1) { + const character = replacement.plaintext[index] + let child = node.children.get(character) + if (!child) { + child = { children: new Map() } + node.children.set(character, child) + nodeCount += 1 + if (nodeCount > MAX_MATCHER_NODES) { + throw new ResolvedSecretContentProjectionError('Secret matcher node limit exceeded') + } + } + node = child + } + node.replacement = replacement + } + + const queue: SecretTrieNode[] = [] + for (const child of root.children.values()) { + child.failure = root + queue.push(child) + } + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const node = queue[cursor] + for (const [character, child] of node.children) { + let fallback = node.failure ?? root + while (fallback !== root && !fallback.children.has(character)) { + fallback = fallback.failure ?? root + } + const transition = fallback.children.get(character) + child.failure = transition && transition !== child ? transition : root + child.outputLink = child.failure.replacement ? child.failure : child.failure.outputLink + queue.push(child) + } + } + + return { root, maxPatternLength } +} + +function advanceMatcher( + matcher: ResolvedSecretMatcher, + node: SecretTrieNode, + character: string +): SecretTrieNode { + let current = node + while (current !== matcher.root && !current.children.has(character)) { + current = current.failure ?? matcher.root + } + return current.children.get(character) ?? matcher.root +} + +export function containsResolvedSecret(value: string, matcher: ResolvedSecretMatcher): boolean { + let node = matcher.root + for (let index = 0; index < value.length; index += 1) { + node = advanceMatcher(matcher, node, value[index]) + if (node.replacement || node.outputLink) return true + } + return false +} + +export function sanitizeResolvedSecretString( + value: string, + matcher: ResolvedSecretMatcher, + maxBytes = MAX_INLINE_MATERIALIZATION_BYTES +): string { + if (maxBytes < 0) { + throw new ResolvedSecretContentProjectionError( + 'Sanitized secret-bearing string exceeds the size limit' + ) + } + if (Buffer.byteLength(value, 'utf8') > maxBytes) { + throw new ResolvedSecretContentProjectionError('Secret-bearing string exceeds the size limit') + } + if (matcher.maxPatternLength === 0 || value.length === 0) return value + + let emitCursor = 0 + let literalStart = 0 + let outputBytes = 0 + let matchEvents = 0 + const chunks: string[] = [] + const windowSize = matcher.maxPatternLength + const slotStarts = new Int32Array(windowSize) + const slotEnds = new Int32Array(windowSize) + slotStarts.fill(-1) + const slotReplacements = new Array(windowSize) + + const append = (chunk: string): void => { + if (!chunk) return + outputBytes += Buffer.byteLength(chunk, 'utf8') + if (outputBytes > maxBytes) { + throw new ResolvedSecretContentProjectionError( + 'Sanitized secret-bearing string exceeds the size limit' + ) + } + const lastIndex = chunks.length - 1 + if (lastIndex >= 0 && chunks[lastIndex].length + chunk.length <= 64 * 1024) { + chunks[lastIndex] += chunk + } else { + chunks.push(chunk) + } + } + + const finalizeThrough = (limit: number): void => { + while (emitCursor <= limit && emitCursor < value.length) { + const slot = emitCursor % windowSize + if (slotStarts[slot] === emitCursor && slotReplacements[slot] !== undefined) { + append(value.slice(literalStart, emitCursor)) + append(slotReplacements[slot] ?? '') + emitCursor = slotEnds[slot] + literalStart = emitCursor + } else { + emitCursor += 1 + } + } + } + + let node = matcher.root + for (let index = 0; index < value.length; index += 1) { + node = advanceMatcher(matcher, node, value[index]) + let outputNode: SecretTrieNode | undefined = node.replacement ? node : node.outputLink + while (outputNode?.replacement) { + matchEvents += 1 + if (matchEvents > MAX_MATCH_EVENTS) { + throw new ResolvedSecretContentProjectionError('Secret matcher event limit exceeded') + } + const start = index - outputNode.replacement.plaintext.length + 1 + if (start >= emitCursor) { + const slot = start % windowSize + const end = index + 1 + if (slotStarts[slot] !== start || end > slotEnds[slot]) { + slotStarts[slot] = start + slotEnds[slot] = end + slotReplacements[slot] = outputNode.replacement.replacement + } + } + outputNode = outputNode.outputLink + } + finalizeThrough(index - matcher.maxPatternLength + 1) + } + + finalizeThrough(value.length - 1) + append(value.slice(literalStart)) + const sanitized = chunks.join('') + if (containsResolvedSecret(sanitized, matcher)) { + throw new ResolvedSecretContentProjectionError( + 'Sanitized content still contains an active secret' + ) + } + return sanitized +} + +export function createResolvedSecretMatcher( + matches: readonly ResolvedSecretTraceMatch[] +): ResolvedSecretMatcher | undefined { + const replacementByPlaintext = new Map() + + for (const match of matches) { + if (!match.plaintext) continue + const current = replacementByPlaintext.get(match.plaintext) + if (current === undefined || compareStrings(match.replacement, current) < 0) { + replacementByPlaintext.set(match.plaintext, match.replacement) + } + } + + const provisional = [...replacementByPlaintext.keys()] + .map((plaintext) => ({ + plaintext, + replacement: replacementByPlaintext.get(plaintext) ?? '', + })) + .sort( + (left, right) => + right.plaintext.length - left.plaintext.length || + compareStrings(left.replacement, right.replacement) || + compareStrings(left.plaintext, right.plaintext) + ) + + if (provisional.length === 0) return undefined + + const detector = createMatcherFromReplacements( + provisional.map(({ plaintext }) => ({ plaintext, replacement: '' })) + ) + return createMatcherFromReplacements( + provisional.map(({ plaintext, replacement }) => ({ + plaintext, + replacement: containsResolvedSecret(replacement, detector) ? '' : replacement, + })) + ) +} + +function visitNode(state: ProjectionState, depth: number): void { + state.nodes += 1 + if (state.nodes > MAX_CONTENT_NODES) { + throw new ResolvedSecretContentProjectionError('Secret-bearing content exceeds node limit') + } + if (depth > MAX_CONTENT_DEPTH) { + throw new ResolvedSecretContentProjectionError('Secret-bearing content exceeds depth limit') + } +} + +function* enumerableDataEntries(value: object): Generator<[string, unknown]> { + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') { + throw new ResolvedSecretContentProjectionError('Content cannot contain symbol properties') + } + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new ResolvedSecretContentProjectionError('Content accessors are not supported') + } + yield [key, descriptor.value] + } +} + +function* arrayDataEntries(value: readonly unknown[]): Generator<[number, unknown]> { + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length') + if ( + !lengthDescriptor || + !('value' in lengthDescriptor) || + typeof lengthDescriptor.value !== 'number' || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 + ) { + throw new ResolvedSecretContentProjectionError('Content array length is invalid') + } + + for (const key of Reflect.ownKeys(value)) { + if (key === 'length') continue + if (typeof key !== 'string') { + throw new ResolvedSecretContentProjectionError('Content arrays cannot contain symbols') + } + const index = Number(key) + if (!Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key) { + throw new ResolvedSecretContentProjectionError('Content array has custom properties') + } + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new ResolvedSecretContentProjectionError('Content array accessors are unsupported') + } + yield [index, descriptor.value] + } +} + +function sanitizeContent( + value: unknown, + matcher: ResolvedSecretMatcher, + state: ProjectionState, + options: ResolvedSecretContentProjectionOptions, + depth = 0 +): unknown { + visitNode(state, depth) + if (typeof value === 'string') { + const sanitized = sanitizeResolvedSecretString( + value, + matcher, + state.maxBytes - state.outputBytes + ) + state.outputBytes += Buffer.byteLength(sanitized, 'utf8') + return sanitized + } + if (value === null || typeof value === 'number' || typeof value === 'boolean') { + const rendered = String(value) + if (!containsResolvedSecret(rendered, matcher)) return value + const sanitized = sanitizeResolvedSecretString( + rendered, + matcher, + state.maxBytes - state.outputBytes + ) + state.outputBytes += Buffer.byteLength(sanitized, 'utf8') + return sanitized + } + if (value === undefined) return value + if (typeof value !== 'object') { + throw new ResolvedSecretContentProjectionError('Unsupported secret-bearing content value') + } + if (options.isOpaqueSafeObject?.(value)) return value + if (!Array.isArray(value) && !isPlainRecord(value)) { + throw new ResolvedSecretContentProjectionError('Unsupported secret-bearing content value') + } + if ( + !Array.isArray(value) && + (Object.hasOwn(value, LARGE_VALUE_REF_MARKER) || + Object.hasOwn(value, LARGE_ARRAY_MANIFEST_MARKER)) + ) { + throw new ResolvedSecretContentProjectionError( + 'Offloaded secret-bearing content cannot cross this boundary' + ) + } + if (state.ancestors.has(value)) { + throw new ResolvedSecretContentProjectionError('Cyclic secret-bearing content is unsupported') + } + + state.ancestors.add(value) + try { + if (Array.isArray(value)) { + if (value.length > MAX_CONTENT_NODES - state.nodes) { + throw new ResolvedSecretContentProjectionError('Content array exceeds traversal limit') + } + const sanitized = new Array(value.length) + for (const [index, item] of arrayDataEntries(value)) { + sanitized[index] = sanitizeContent(item, matcher, state, options, depth + 1) + } + return sanitized + } + + const sanitized = Object.create(Object.getPrototypeOf(value)) as Record + const sanitizedKeys = new Set() + for (const [key, item] of enumerableDataEntries(value)) { + const sanitizedKey = sanitizeResolvedSecretString( + key, + matcher, + state.maxBytes - state.outputBytes + ) + state.outputBytes += Buffer.byteLength(sanitizedKey, 'utf8') + if (sanitizedKeys.has(sanitizedKey)) { + throw new ResolvedSecretContentProjectionError( + 'Secret replacement caused an object-key collision' + ) + } + sanitizedKeys.add(sanitizedKey) + Object.defineProperty(sanitized, sanitizedKey, { + value: sanitizeContent(item, matcher, state, options, depth + 1), + enumerable: true, + configurable: true, + writable: true, + }) + } + return sanitized + } finally { + state.ancestors.delete(value) + } +} + +export function projectResolvedSecretContent( + value: unknown, + matcher: ResolvedSecretMatcher, + maxBytes = MAX_INLINE_MATERIALIZATION_BYTES, + options: ResolvedSecretContentProjectionOptions = {} +): ResolvedSecretContentProjection { + try { + return { + safe: true, + value: sanitizeContent( + value, + matcher, + { + nodes: 0, + ancestors: new WeakSet(), + outputBytes: 0, + maxBytes, + }, + options + ), + } + } catch { + return { safe: false } + } +} diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts index 6b435afff6d..1498c98f45a 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts @@ -164,6 +164,34 @@ describe('ResolvedSecretTraceRegistry', () => { ]) }) + it('fails closed while one or more secret activations are pending', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-value' }, + ]) + const completeFirst = registry.beginPendingActivation() + const completeSecond = registry.beginPendingActivation() + + expect(registry.isComplete()).toBe(false) + expect(registry.exportProvenance()).toEqual({ + version: 1, + complete: false, + entries: [], + }) + + registry.recordResolved('API_KEY', 'secret-value') + completeFirst() + expect(registry.isComplete()).toBe(false) + + completeSecond() + completeSecond() + expect(registry.isComplete()).toBe(true) + expect(registry.exportProvenance()).toEqual({ + version: 1, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-value' }], + }) + }) + it('uses the workspace catalog entry when personal and workspace names conflict', async () => { const registry = await createResolvedSecretTraceRegistry({ personalEncrypted: { SHARED: 'personal-encrypted' }, diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index 632a1a56872..f96694e76ec 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -415,6 +415,7 @@ export class ResolvedSecretTraceRegistry { private readonly activeEntries = new Map() private activeProvenanceEntryBytes = 0 private complete = true + private pendingActivations = 0 private readonly scope?: ResolvedSecretTraceScopeV1 private readonly completeProvenanceEnvelopeBytes: number @@ -540,22 +541,40 @@ export class ResolvedSecretTraceRegistry { } isComplete(): boolean { - return this.complete + return this.complete && this.pendingActivations === 0 + } + + isPermanentlyIncomplete(): boolean { + return !this.complete } markIncomplete(): void { this.complete = false } + /** + * Makes projections fail closed while an exact runtime substitution is being established. + * The returned completion callback is idempotent so every exit path can safely release it. + */ + beginPendingActivation(): () => void { + this.pendingActivations += 1 + let completed = false + + return () => { + if (completed) return + completed = true + this.pendingActivations = Math.max(0, this.pendingActivations - 1) + } + } + /** Serializes only encrypted active values; plaintext never enters execution state. */ exportProvenance(): ResolvedSecretTraceProvenanceV1 { - const entries = this.complete - ? this.buildProvenanceEntries([...this.activeEntries.values()]) - : [] + const complete = this.isComplete() + const entries = complete ? this.buildProvenanceEntries([...this.activeEntries.values()]) : [] return { version: 1, - complete: this.complete, + complete, entries, ...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}), } @@ -569,7 +588,7 @@ export class ResolvedSecretTraceRegistry { value: unknown, options: ExportResolvedSecretTraceProvenanceForValueOptions = {} ): ResolvedSecretTraceProvenanceV1 { - if (!this.complete) return { version: 1, complete: false, entries: [] } + if (!this.isComplete()) return { version: 1, complete: false, entries: [] } const candidatesByPlaintext = new Map() const sortedActiveEntries = [...this.activeEntries.values()].sort( diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 25daaf06179..a284acaeb12 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -21,7 +21,10 @@ import { } from '@/lib/api/contracts' import { environmentKeys } from '@/hooks/queries/environment' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' -import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-workspace-credentials' +import { + fetchWorkspaceCredentialList, + WORKSPACE_CREDENTIAL_LIST_STALE_TIME, +} from '@/hooks/queries/utils/fetch-workspace-credentials' /** * Key prefix for OAuth credential queries. @@ -29,7 +32,6 @@ import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-worksp */ const OAUTH_CREDENTIALS_KEY = ['oauthCredentials'] as const -export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 export const WORKSPACE_CREDENTIAL_DETAIL_STALE_TIME = 60 * 1000 export const WORKSPACE_CREDENTIAL_MEMBER_LIST_STALE_TIME = 30 * 1000 diff --git a/apps/sim/hooks/queries/inbox.ts b/apps/sim/hooks/queries/inbox.ts index 9577ddeee6a..6b2a7d4f1f8 100644 --- a/apps/sim/hooks/queries/inbox.ts +++ b/apps/sim/hooks/queries/inbox.ts @@ -12,6 +12,7 @@ import { listInboxSendersContract, listInboxTasksContract, removeInboxSenderContract, + type SecretMountPolicyInput, updateInboxConfigContract, } from '@/lib/api/contracts' @@ -140,6 +141,37 @@ export function useUpdateInboxAddress() { }) } +export function useUpdateInboxSecretPolicy() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + workspaceId, + ...policy + }: { workspaceId: string } & Required) => { + return requestJson(updateInboxConfigContract, { + params: { id: workspaceId }, + body: policy, + }) + }, + onMutate: async ({ workspaceId, ...policy }) => { + const queryKey = inboxKeys.config(workspaceId) + await queryClient.cancelQueries({ queryKey }) + const previous = queryClient.getQueryData(queryKey) + if (previous) queryClient.setQueryData(queryKey, { ...previous, ...policy }) + return { previous } + }, + onError: (_error, variables, context) => { + if (context?.previous) { + queryClient.setQueryData(inboxKeys.config(variables.workspaceId), context.previous) + } + }, + onSettled: (_data, _error, variables) => { + return queryClient.invalidateQueries({ queryKey: inboxKeys.config(variables.workspaceId) }) + }, + }) +} + export function useAddInboxSender() { const queryClient = useQueryClient() diff --git a/apps/sim/hooks/queries/secret-mount-options.ts b/apps/sim/hooks/queries/secret-mount-options.ts new file mode 100644 index 00000000000..6e678c470d7 --- /dev/null +++ b/apps/sim/hooks/queries/secret-mount-options.ts @@ -0,0 +1,19 @@ +'use client' + +import { useMemo } from 'react' +import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' +import { useWorkspaceCredentials } from '@/hooks/queries/credentials' + +export function useRawMountableSecretOptions(workspaceId?: string) { + const query = useWorkspaceCredentials({ workspaceId }) + const options = useMemo( + () => + selectRawMountableSecretNames(query.data ?? []).map((name) => ({ + value: name, + label: name, + })), + [query.data] + ) + + return { options, isPending: query.isPending } +} diff --git a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts index 9fd8efd7b6f..bf1dccfe9d3 100644 --- a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts +++ b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts @@ -1,6 +1,8 @@ import { requestJson } from '@/lib/api/client/request' import { listWorkspaceCredentialsContract, type WorkspaceCredential } from '@/lib/api/contracts' +export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 + /** * Fetches the workspace credential list. * diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index e7136c78dc7..8da3f338c30 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -47,6 +47,7 @@ export const copilotCredentialsQuerySchema = z.object({}) export const copilotConfirmBodySchema = z.object({ toolCallId: z.string().min(1, 'Tool call ID is required'), + executionId: z.string().min(1, 'Execution ID is required').max(255).optional(), status: z.enum( Object.values(ASYNC_TOOL_CONFIRMATION_STATUS) as [ AsyncConfirmationStatus, diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index db8e82adb6b..197ebec5c22 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -80,6 +80,7 @@ export const deploymentOperationSummarySchema = z.object({ version: z.number().int().positive(), action: z.enum(DEPLOYMENT_OPERATION_ACTIONS), status: deploymentOperationStatusSchema, + isCurrent: z.boolean().optional().default(true), readiness: deploymentReadinessSchema, requestedAt: z.string(), activatedAt: z.string().nullable().optional(), diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts index eaa75f8f430..75acb8f86de 100644 --- a/apps/sim/lib/api/contracts/hotspots.ts +++ b/apps/sim/lib/api/contracts/hotspots.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { customPatternSchema, unknownRecordSchema } from '@/lib/api/contracts/primitives' +import { + customPatternSchema, + stringRecordSchema, + unknownRecordSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages' export const guardrailsValidateContract = defineRouteContract({ @@ -175,9 +179,9 @@ export const functionExecuteContract = defineRouteContract({ }) .strict() .optional(), - envVars: z.record(z.string(), z.string()).optional().default({}), + envVars: stringRecordSchema.optional().default({}), blockData: unknownRecordSchema.optional().default({}), - blockNameMapping: z.record(z.string(), z.string()).optional().default({}), + blockNameMapping: stringRecordSchema.optional().default({}), blockOutputSchemas: z.record(z.string(), unknownRecordSchema).optional().default({}), workflowVariables: unknownRecordSchema.optional().default({}), contextVariables: unknownRecordSchema.optional().default({}), diff --git a/apps/sim/lib/api/contracts/inbox.ts b/apps/sim/lib/api/contracts/inbox.ts index 34152f3346b..a3e8387c866 100644 --- a/apps/sim/lib/api/contracts/inbox.ts +++ b/apps/sim/lib/api/contracts/inbox.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + mountedSecretNamesSchema, + secretMountScopeSchema, +} from '@/lib/api/contracts/secret-mount-policy' import { defineRouteContract } from '@/lib/api/contracts/types' export const inboxWorkspaceParamsSchema = z.object({ @@ -17,6 +21,8 @@ export const inboxTaskStatusSchema = z.enum([ export const inboxConfigSchema = z.object({ enabled: z.boolean(), address: z.string().nullable(), + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, entitled: z.boolean(), taskStats: z.object({ total: z.number(), @@ -32,12 +38,16 @@ export type InboxTaskStatus = z.output export const updateInboxConfigBodySchema = z.object({ enabled: z.boolean().optional(), username: z.string().min(1).max(64).optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), }) export const updateInboxConfigResponseSchema = z.object({ enabled: z.boolean(), address: z.string().nullable(), providerId: z.string().nullable().optional(), + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, }) export const inboxSenderSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 2001b85b8c2..10ad693347c 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -22,6 +22,7 @@ export * from './permission-groups' export * from './pinned-items' export * from './primitives' export * from './sandboxes' +export * from './secret-mount-policy' export * from './selectors' export * from './skills' export * from './storage-transfer' diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index 70a297a486d..275f3c80bf5 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -1,5 +1,9 @@ import { z } from 'zod' import { scheduleContextSchema } from '@/lib/api/contracts/schedules' +import { + mountedSecretNamesSchema, + secretMountScopeSchema, +} from '@/lib/api/contracts/secret-mount-policy' import { defineRouteContract } from '@/lib/api/contracts/types' const dateStringSchema = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { @@ -119,6 +123,8 @@ export const mothershipExecuteBodySchema = z.object({ mcpTools: z.array(mothershipExecuteMcpToolSchema).optional(), workflowId: z.string().optional(), executionId: z.string().optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), userMetadata: z .object({ name: z.string().optional(), diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index a0bfff57299..f899a27174f 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -1,9 +1,25 @@ +import { isPlainRecord } from '@sim/utils/object' import { z } from 'zod' +import { setRecordValue } from '@/lib/core/utils/records' import { PII_LANGUAGE_CODES, stripNerEntities } from '@/lib/guardrails/pii-entities' import { validateRegexPattern } from '@/lib/guardrails/validate_regex' export const unknownRecordSchema = z.record(z.string(), z.unknown()) +export const stringRecordSchema = z + .custom>( + (value) => + isPlainRecord(value) && Object.values(value).every((entry) => typeof entry === 'string'), + { error: 'Expected a record of string values' } + ) + .transform((value) => { + const record: Record = {} + for (const [key, entry] of Object.entries(value)) { + setRecordValue(record, key, entry) + } + return record + }) + export function flattenFieldErrors( error: z.ZodError ): Partial> { diff --git a/apps/sim/lib/api/contracts/schedules.ts b/apps/sim/lib/api/contracts/schedules.ts index d4eaf3d5e11..3a707e59232 100644 --- a/apps/sim/lib/api/contracts/schedules.ts +++ b/apps/sim/lib/api/contracts/schedules.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + mountedSecretNamesSchema, + secretMountScopeSchema, +} from '@/lib/api/contracts/secret-mount-policy' import { defineRouteContract } from '@/lib/api/contracts/types' export const scheduleStatusSchema = z.enum(['active', 'disabled', 'completed']) @@ -74,6 +78,8 @@ export const workflowScheduleRowSchema = z.object({ sourceTaskName: z.string().nullable(), sourceUserId: z.string().nullable(), sourceWorkspaceId: z.string().nullable(), + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, jobHistory: z.array(z.object({ timestamp: z.string(), summary: z.string() })).nullable(), contexts: z.array(scheduleContextSchema).nullable(), excludedDates: z.array(z.string()).nullable(), @@ -113,6 +119,8 @@ export const createScheduleBodySchema = z endsAt: z.string().optional(), startDate: z.string().optional(), contexts: z.array(scheduleContextSchema).optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), }) .superRefine((body, ctx) => { if (!body.cronExpression && !body.time) { @@ -150,6 +158,8 @@ export const updateScheduleBodySchema = z.object({ maxRuns: z.number().int().positive().nullable().optional(), endsAt: z.string().nullable().optional(), contexts: z.array(scheduleContextSchema).optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), }) export type UpdateScheduleBody = z.input diff --git a/apps/sim/lib/api/contracts/secret-mount-policy.test.ts b/apps/sim/lib/api/contracts/secret-mount-policy.test.ts new file mode 100644 index 00000000000..f1898448ebc --- /dev/null +++ b/apps/sim/lib/api/contracts/secret-mount-policy.test.ts @@ -0,0 +1,32 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { mountedSecretNamesSchema } from '@/lib/api/contracts/secret-mount-policy' +import { + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, +} from '@/lib/copilot/secret-mount-policy' + +describe('mountedSecretNamesSchema', () => { + it('accepts the bounded names-only policy shape', () => { + expect(mountedSecretNamesSchema.parse([' API_KEY ', 'name-with-dashes'])).toEqual([ + 'API_KEY', + 'name-with-dashes', + ]) + }) + + it('rejects too many names', () => { + expect(() => + mountedSecretNamesSchema.parse( + Array.from({ length: MAX_SECRET_MOUNT_NAMES + 1 }, (_, index) => `SECRET_${index}`) + ) + ).toThrow() + }) + + it('rejects an overlong name without narrowing the runtime name grammar', () => { + expect(() => + mountedSecretNamesSchema.parse(['S'.repeat(MAX_SECRET_MOUNT_NAME_LENGTH + 1)]) + ).toThrow() + }) +}) diff --git a/apps/sim/lib/api/contracts/secret-mount-policy.ts b/apps/sim/lib/api/contracts/secret-mount-policy.ts new file mode 100644 index 00000000000..d3306ef7ea9 --- /dev/null +++ b/apps/sim/lib/api/contracts/secret-mount-policy.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' +import { + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, +} from '@/lib/copilot/secret-mount-policy' + +export const secretMountScopeSchema = z.enum(['all', 'selected']) + +export const mountedSecretNameSchema = z.string().trim().min(1).max(MAX_SECRET_MOUNT_NAME_LENGTH) + +export const mountedSecretNamesSchema = z.array(mountedSecretNameSchema).max(MAX_SECRET_MOUNT_NAMES) + +export const secretMountPolicySchema = z.object({ + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, +}) + +export const secretMountPolicyInputSchema = secretMountPolicySchema.partial() + +export type SecretMountPolicyInput = z.input +export type SecretMountPolicyOutput = z.output diff --git a/apps/sim/lib/api/contracts/v1/workflows.ts b/apps/sim/lib/api/contracts/v1/workflows.ts index 10ef1c35205..8f15312ffaf 100644 --- a/apps/sim/lib/api/contracts/v1/workflows.ts +++ b/apps/sim/lib/api/contracts/v1/workflows.ts @@ -102,7 +102,9 @@ const v1DeploymentStateSchema = z.object({ * accepted, while `isDeployed` reflects whether a version is actually live. * `latestDeploymentAttempt` carries the lifecycle status * (preparing/activating/active/failed/superseded) so API consumers can poll - * to a terminal state instead of guessing from `isDeployed` alone. + * to a terminal state instead of guessing from `isDeployed` alone. Its + * `isCurrent` field is false when the operation is historical and no longer + * describes the active deployment. */ const v1DeploymentLifecycleSchema = v1DeploymentStateSchema.extend({ activeDeployment: activeDeploymentSummarySchema.nullable(), diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index c254daf74ff..89a926caf38 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -369,6 +369,7 @@ export const executeWorkflowBodySchema = z.object({ /** Internal MCP bridge pin for calls admitted before a deployment cutover. */ deploymentVersionId: z.string().min(1).optional(), executionId: z.unknown().optional(), + copilotToolCallId: z.string().min(1).max(255).optional(), triggerBlockId: z.string().optional(), startBlockId: z.string().optional(), stopAfterBlockId: z.string().optional(), diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index e7efb1a0e28..87233c9fc40 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1,13 +1,10 @@ -import { createHash } from 'crypto' import { cache } from 'react' -import { getOAuth2Tokens } from '@better-auth/core/oauth2' import { sso } from '@better-auth/sso' import { stripe } from '@better-auth/stripe' import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { type BetterAuthOptions, betterAuth, type User } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api' @@ -33,6 +30,7 @@ import { } from '@/components/emails' import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' +import { buildConnectorProviders } from '@/lib/auth/connectors/providers' import { getRequestedSignInProviderId, isSignInProviderAllowed } from '@/lib/auth/constants' import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy' import { clampExpiryForSession } from '@/lib/auth/session-policy' @@ -86,10 +84,6 @@ import { isSsoEnabled, } from '@/lib/core/config/env-flags' import { PlatformEvents } from '@/lib/core/telemetry' -import { - readResponseJsonWithLimit, - readResponseTextWithLimit, -} from '@/lib/core/utils/stream-limits' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { processCredentialDraft } from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -97,11 +91,7 @@ import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email import { quickValidateEmail } from '@/lib/messaging/email/validation' import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server' import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle' -import { - deriveMicrosoftEmailVerified, - getMicrosoftRefreshTokenExpiry, - isMicrosoftProvider, -} from '@/lib/oauth/microsoft' +import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft' import { extractSlackTeamId, fanOutSlackTokenChain } from '@/lib/oauth/slack' import { clearDeadFlag } from '@/lib/oauth/terminal-errors' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' @@ -109,57 +99,9 @@ import { joinInstanceOrganization } from '@/lib/organizations/instance-org' import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server' import { disableUserResources } from '@/lib/workflows/lifecycle' import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' -import { deriveZohoDeskBaseFromApiDomain } from '@/tools/zoho_desk/host-allowlist' const logger = createLogger('Auth') -/** - * Extracts user info from a Microsoft ID token JWT instead of calling Graph API /me. - * This avoids 403 errors for external tenant users whose admin hasn't consented to Graph API scopes. - * The ID token is always returned when the openid scope is requested. - */ -function getMicrosoftUserInfoFromIdToken(tokens: { accessToken?: string }, providerId: string) { - const idToken = (tokens as Record).idToken as string | undefined - if (!idToken) { - logger.error( - `Microsoft ${providerId} OAuth: no ID token received. Ensure openid scope is requested.` - ) - throw new Error(`Microsoft ${providerId} OAuth requires an ID token (openid scope)`) - } - - const parts = idToken.split('.') - if (parts.length !== 3) { - throw new Error(`Microsoft ${providerId} OAuth: malformed ID token`) - } - - let payload: Record - try { - payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) - } catch { - throw new Error(`Microsoft ${providerId} OAuth: failed to decode ID token payload`) - } - - const email = - (payload.email as string) || (payload.preferred_username as string) || (payload.upn as string) - if (!email) { - throw new Error( - `Microsoft ${providerId} OAuth: ID token contains no email, preferred_username, or upn claim` - ) - } - - const emailVerified = deriveMicrosoftEmailVerified(payload, email) - - const now = new Date() - return { - id: `${payload.oid || payload.sub}-${generateId()}`, - name: (payload.name as string) || 'Microsoft User', - email, - emailVerified, - createdAt: now, - updatedAt: now, - } -} - const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) => logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value }) ) @@ -452,8 +394,9 @@ export const auth = betterAuth({ /** * Migrate credentials from stale account rows to the newly created one. * - * Each getUserInfo appends a random UUID to the stable external ID so - * that Better Auth never blocks cross-user connections. This means + * Each `getUserInfo` in `lib/auth/connectors/providers.ts` appends a + * random UUID to the stable external ID so that Better Auth never + * blocks cross-user connections — keep the two in step. This means * re-connecting the same external identity creates a new row. We detect * the stale siblings here by comparing the stable prefix (everything * before the trailing UUID), migrate any credential FKs to the new row, @@ -1131,2282 +1074,7 @@ export const auth = betterAuth({ overrideDefaultEmailVerification: true, }), genericOAuth({ - config: [ - { - providerId: 'google-email', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-email'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-email`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-calendar', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-calendar'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-calendar`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-drive', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-drive'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-drive`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-docs', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-docs'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-docs`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-sheets', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-sheets'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-sheets`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - - { - providerId: 'google-contacts', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-contacts'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-contacts`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-forms', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-forms'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-forms`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-ads', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-ads'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-ads`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-bigquery', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-bigquery'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-bigquery`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - - { - providerId: 'google-vault', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-vault'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-vault`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - - { - providerId: 'google-groups', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-groups'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-groups`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - - { - providerId: 'google-meet', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-meet'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-meet`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-tasks', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-tasks'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-tasks`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - - { - providerId: 'vertex-ai', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('vertex-ai'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/vertex-ai`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - - { - providerId: 'microsoft-ad', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('microsoft-ad'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-ad`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-ad') - }, - }, - - { - providerId: 'microsoft-teams', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('microsoft-teams'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-teams`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-teams') - }, - }, - - { - providerId: 'microsoft-excel', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('microsoft-excel'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-excel`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-excel') - }, - }, - { - providerId: 'microsoft-dataverse', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('microsoft-dataverse'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-dataverse`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-dataverse') - }, - }, - { - providerId: 'microsoft-planner', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('microsoft-planner'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-planner`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-planner') - }, - }, - - { - providerId: 'outlook', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('outlook'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/outlook`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'outlook') - }, - }, - - { - providerId: 'onedrive', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('onedrive'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/onedrive`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'onedrive') - }, - }, - - { - providerId: 'sharepoint', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('sharepoint'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/sharepoint`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'sharepoint') - }, - }, - - { - providerId: 'wealthbox', - clientId: env.WEALTHBOX_CLIENT_ID as string, - clientSecret: env.WEALTHBOX_CLIENT_SECRET as string, - authorizationUrl: 'https://app.crmworkspace.com/oauth/authorize', - tokenUrl: 'https://app.crmworkspace.com/oauth/token', - userInfoUrl: 'https://api.crmworkspace.com/v1/me', - scopes: getCanonicalScopesForProvider('wealthbox'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wealthbox`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching Wealthbox user profile') - - const response = await fetch('https://api.crmworkspace.com/v1/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - const now = new Date() - - if (response.ok) { - const data = await response.json() - const userId = data.id?.toString() - if (!userId) { - return null - } - const email = - data.email && typeof data.email === 'string' - ? data.email - : `wealthbox-${userId}@wealthbox.user` - const name = data.name || data.full_name || data.username || 'Wealthbox User' - - return { - id: `wealthbox-${userId}-${generateId()}`, - name, - email, - emailVerified: false, - createdAt: now, - updatedAt: now, - } - } - - // Fallback: derive a stable identifier from the refresh token (long-lived) - // rather than the access token (rotates every ~2 hours) to avoid creating - // duplicate accounts on token refresh. - logger.warn( - 'Wealthbox user info fetch failed, falling back to token-derived identity', - { - status: response.status, - } - ) - const stableToken = tokens.refreshToken ?? tokens.accessToken - if (!stableToken) { - logger.error('Wealthbox fallback identity: no refresh or access token available') - return null - } - const tokenHash = createHash('sha256').update(stableToken).digest('hex').slice(0, 24) - return { - id: `wealthbox-${tokenHash}-${generateId()}`, - name: 'Wealthbox User', - email: `wealthbox-${tokenHash}@wealthbox.user`, - emailVerified: false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error creating Wealthbox user profile:', { - error: toError(error).message, - }) - return null - } - }, - }, - - { - providerId: 'pipedrive', - clientId: env.PIPEDRIVE_CLIENT_ID as string, - clientSecret: env.PIPEDRIVE_CLIENT_SECRET as string, - authorizationUrl: 'https://oauth.pipedrive.com/oauth/authorize', - tokenUrl: 'https://oauth.pipedrive.com/oauth/token', - userInfoUrl: 'https://api.pipedrive.com/v1/users/me', - prompt: 'consent', - scopes: getCanonicalScopesForProvider('pipedrive'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/pipedrive`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching Pipedrive user profile') - - const response = await fetch('https://api.pipedrive.com/v1/users/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Pipedrive user info', { - status: response.status, - }) - throw new Error('Failed to fetch user info') - } - - const data = await response.json() - const user = data.data - - return { - id: `${user.id.toString()}-${generateId()}`, - name: user.name, - email: user.email, - emailVerified: user.activated, - image: user.icon_url, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error creating Pipedrive user profile:', { error }) - return null - } - }, - }, - - { - providerId: 'hubspot', - clientId: env.HUBSPOT_CLIENT_ID as string, - clientSecret: env.HUBSPOT_CLIENT_SECRET as string, - authorizationUrl: 'https://app.hubspot.com/oauth/authorize', - tokenUrl: 'https://api.hubapi.com/oauth/v1/token', - userInfoUrl: 'https://api.hubapi.com/oauth/v1/access-tokens', - prompt: 'consent', - scopes: getCanonicalScopesForProvider('hubspot'), - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/hubspot`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching HubSpot user profile') - - const response = await fetch( - `https://api.hubapi.com/oauth/v1/access-tokens/${tokens.accessToken}` - ) - - if (!response.ok) { - let errorBody: string | undefined - try { - errorBody = await response.text() - } catch { - // ignore - } - logger.error('Failed to fetch HubSpot user info', { - status: response.status, - statusText: response.statusText, - body: errorBody?.slice(0, 500), - }) - throw new Error('Failed to fetch user info') - } - - const rawText = await response.text() - const data = JSON.parse(rawText) - - const scopesArray = Array.isArray((data as any)?.scopes) ? (data as any).scopes : [] - if (Array.isArray(scopesArray) && scopesArray.length > 0) { - tokens.scopes = scopesArray - } else if (typeof (data as any)?.scope === 'string') { - tokens.scopes = (data as any).scope.split(/\s+/).filter(Boolean) - } - - logger.info('HubSpot token metadata response:', { - hubId: data.hub_id, - hubDomain: data.hub_domain, - userId: data.user_id, - hasScopes: !!data.scopes, - scopesType: typeof data.scopes, - scopesIsArray: Array.isArray(data.scopes), - }) - - return { - id: `${(data.user_id || data.hub_id).toString()}-${generateId()}`, - name: data.user || 'HubSpot User', - email: data.user || `hubspot-${data.hub_id}@hubspot.com`, - emailVerified: true, - image: undefined, - createdAt: new Date(), - updatedAt: new Date(), - // Extract scopes from HubSpot's response and convert array to space-delimited string - // Use 'scope' (singular) as that's what better-auth expects for the account table - ...(data.scopes && Array.isArray(data.scopes) - ? { scope: data.scopes.join(' ') } - : {}), - } - } catch (error) { - logger.error('Error creating HubSpot user profile:', { error }) - return null - } - }, - }, - - { - providerId: 'salesforce', - clientId: env.SALESFORCE_CLIENT_ID as string, - clientSecret: env.SALESFORCE_CLIENT_SECRET as string, - authorizationUrl: 'https://login.salesforce.com/services/oauth2/authorize', - tokenUrl: 'https://login.salesforce.com/services/oauth2/token', - userInfoUrl: 'https://login.salesforce.com/services/oauth2/userinfo', - scopes: getCanonicalScopesForProvider('salesforce'), - pkce: true, - prompt: 'consent', - accessType: 'offline', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/salesforce`, - getUserInfo: async (tokens) => { - try { - const response = await fetch( - 'https://login.salesforce.com/services/oauth2/userinfo', - { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - } - ) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Salesforce user info', { - status: response.status, - }) - throw new Error('Failed to fetch user info') - } - - const data = await response.json() - - return { - id: `${(data.user_id || data.sub).toString()}-${generateId()}`, - name: data.name || 'Salesforce User', - email: data.email || `salesforce-${data.user_id}@salesforce.com`, - emailVerified: data.email_verified === true, - image: data.picture || undefined, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error creating Salesforce user profile:', { error }) - return null - } - }, - }, - - { - providerId: 'zoho-desk', - clientId: env.ZOHO_CLIENT_ID as string, - clientSecret: env.ZOHO_CLIENT_SECRET as string, - authorizationUrl: 'https://accounts.zoho.com/oauth/v2/auth', - tokenUrl: 'https://accounts.zoho.com/oauth/v2/token', - scopes: getCanonicalScopesForProvider('zoho-desk'), - responseType: 'code', - pkce: true, - accessType: 'offline', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/zoho-desk`, - // Zoho only issues a refresh token when access_type=offline AND - // prompt=consent are present on the authorize request, and it expects - // comma-separated scopes rather than the default space-delimited list. - authorizationUrlParams: { - access_type: 'offline', - prompt: 'consent', - scope: getCanonicalScopesForProvider('zoho-desk').join(','), - }, - getToken: async ({ code, redirectURI, codeVerifier }) => { - const tokenParams = new URLSearchParams({ - client_id: env.ZOHO_CLIENT_ID as string, - client_secret: env.ZOHO_CLIENT_SECRET as string, - code, - grant_type: 'authorization_code', - redirect_uri: redirectURI, - }) - // PKCE is enabled, so better-auth sent a code_challenge on the authorize - // request. The exchange MUST echo the matching code_verifier or Zoho - // rejects the request shape (invalid_request). Verified by isolating - // pkce:false (which connected) then restoring pkce:true + this verifier. - if (codeVerifier) tokenParams.set('code_verifier', codeVerifier) - - const response = await fetch('https://accounts.zoho.com/oauth/v2/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: tokenParams, - }) - const data = await readResponseJsonWithLimit>(response, { - maxBytes: 1024 * 1024, - label: 'Zoho Desk OAuth token response', - }) - - // Zoho signals OAuth failures in the JSON body, usually with HTTP 200, - // e.g. { error: 'invalid_code' } or { error: 'invalid_client', - // error_description: '...' }. The status-only guard therefore never - // fires, so surface the actual error/description instead of collapsing - // every failure into one opaque "no access token" string. - const errorObj = - data && typeof data === 'object' && !Array.isArray(data) - ? (data as { error?: unknown; error_description?: unknown }) - : {} - const zohoError = typeof errorObj.error === 'string' ? errorObj.error : undefined - const zohoErrorDescription = - typeof errorObj.error_description === 'string' - ? errorObj.error_description - : undefined - if ( - !response.ok || - !data || - typeof data !== 'object' || - Array.isArray(data) || - zohoError - ) { - logger.error('Zoho Desk OAuth token exchange failed', { - status: response.status, - zohoError: zohoError ?? null, - zohoErrorDescription: zohoErrorDescription ?? null, - }) - throw new Error( - `Zoho Desk OAuth token exchange failed (HTTP ${response.status}${ - zohoError ? `, ${zohoError}` : '' - }${zohoErrorDescription ? `: ${zohoErrorDescription}` : ''})` - ) - } - - const tokens = getOAuth2Tokens(data) - if (!tokens.accessToken) { - logger.error('Zoho Desk OAuth token response had no access token', { - status: response.status, - bodyKeys: Object.keys(data), - }) - throw new Error('Zoho Desk OAuth token response did not include an access token') - } - - // Persist the data-center-scoped Desk REST base derived from the - // token response api_domain so every API call targets the correct - // host instead of assuming desk.zoho.com. Stored inside the scope - // string (survives refreshes, which never rewrite scope) and read - // back in /api/auth/oauth/token as `apiDomain`. - const deskBase = deriveZohoDeskBaseFromApiDomain( - typeof data.api_domain === 'string' ? data.api_domain : undefined - ) - // Zoho's docs are inconsistent about whether the Desk token response - // carries `scope` (the Mail sample has it; the CRM/Creator samples do - // not). If it is absent, fall back to the scopes we requested and were - // granted by completing the flow - otherwise the stored scope list is - // just the domain marker, and the credential picker would show a - // permanent "needs update / reconnect" badge on every connection. - // Mirrors the existing Box fallback in this file. - const reportedScopes = - typeof data.scope === 'string' ? data.scope.split(/[\s,]+/).filter(Boolean) : [] - const grantedScopes = reportedScopes.length - ? reportedScopes - : getCanonicalScopesForProvider('zoho-desk') - tokens.scopes = [`__zoho_domain__:${deskBase}`, ...grantedScopes] - return tokens - }, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://accounts.zoho.com/oauth/user/info', { - headers: { Authorization: `Zoho-oauthtoken ${tokens.accessToken}` }, - }) - - if (!response.ok) { - await readResponseTextWithLimit(response, { - maxBytes: 1024 * 1024, - label: 'Zoho Desk profile error response', - }).catch(() => {}) - logger.error('Error fetching Zoho Desk user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const profile = await readResponseJsonWithLimit<{ - ZUID?: number | string - Display_Name?: string - Email?: string - }>(response, { maxBytes: 1024 * 1024, label: 'Zoho Desk profile response' }) - - const zuid = profile.ZUID?.toString() - if (!zuid) { - logger.error('Invalid Zoho Desk profile response:', profile) - return null - } - - const now = new Date() - return { - id: `${zuid}-${generateId()}`, - name: profile.Display_Name || 'Zoho User', - email: profile.Email || `zoho-${zuid}@zoho.user`, - emailVerified: Boolean(profile.Email), - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Zoho Desk getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'x', - clientId: env.X_CLIENT_ID as string, - clientSecret: env.X_CLIENT_SECRET as string, - authorizationUrl: 'https://x.com/i/oauth2/authorize', - tokenUrl: 'https://api.x.com/2/oauth2/token', - userInfoUrl: 'https://api.x.com/2/users/me', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('x'), - pkce: true, - responseType: 'code', - prompt: 'consent', - authentication: 'basic', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/x`, - getUserInfo: async (tokens) => { - try { - const response = await fetch( - 'https://api.x.com/2/users/me?user.fields=profile_image_url,username,name,verified', - { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - } - ) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching X user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const profile = await response.json() - - if (!profile.data) { - logger.error('Invalid X profile response:', profile) - return null - } - - const now = new Date() - - return { - id: `${profile.data.id.toString()}-${generateId()}`, - name: profile.data.name || 'X User', - email: `${profile.data.username}@x.com`, - image: profile.data.profile_image_url, - emailVerified: profile.data.verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in X getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'tiktok', - clientId: env.TIKTOK_CLIENT_ID as string, - clientSecret: env.TIKTOK_CLIENT_SECRET as string, - authorizationUrl: 'https://www.tiktok.com/v2/auth/authorize/', - tokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/', - scopes: getCanonicalScopesForProvider('tiktok'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/tiktok`, - authorizationUrlParams: { - client_key: env.TIKTOK_CLIENT_ID as string, - scope: getCanonicalScopesForProvider('tiktok').join(','), - }, - getToken: async ({ code, redirectURI }) => { - const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_key: env.TIKTOK_CLIENT_ID as string, - client_secret: env.TIKTOK_CLIENT_SECRET as string, - code, - grant_type: 'authorization_code', - redirect_uri: redirectURI, - }), - }) - const data = await readResponseJsonWithLimit>(response, { - maxBytes: 1024 * 1024, - label: 'TikTok OAuth token response', - }) - - if (!response.ok || !data || typeof data !== 'object' || Array.isArray(data)) { - throw new Error(`TikTok OAuth token exchange failed with HTTP ${response.status}`) - } - - const tokens = getOAuth2Tokens(data) - if (!tokens.accessToken) { - throw new Error('TikTok OAuth token response did not include an access token') - } - if (typeof data.scope === 'string') { - tokens.scopes = data.scope.split(/[\s,]+/).filter(Boolean) - } - return tokens - }, - getUserInfo: async (tokens) => { - try { - const response = await fetch( - 'https://open.tiktokapis.com/v2/user/info/?fields=open_id,display_name,avatar_url', - { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - } - ) - - if (!response.ok) { - await readResponseTextWithLimit(response, { - maxBytes: 1024 * 1024, - label: 'TikTok profile error response', - }).catch(() => {}) - logger.error('Error fetching TikTok user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const profile = await readResponseJsonWithLimit<{ - data?: { - user?: { - avatar_url?: string - display_name?: string - open_id?: string - } - } - }>(response, { - maxBytes: 1024 * 1024, - label: 'TikTok profile response', - }) - const user = profile.data?.user - - if (!user?.open_id) { - logger.error('Invalid TikTok profile response:', profile) - return null - } - - const now = new Date() - - return { - id: `${user.open_id}-${generateId()}`, - name: user.display_name || 'TikTok User', - email: `${user.open_id}@tiktok.user`, - image: user.avatar_url || undefined, - emailVerified: false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in TikTok getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'confluence', - clientId: env.CONFLUENCE_CLIENT_ID as string, - clientSecret: env.CONFLUENCE_CLIENT_SECRET as string, - authorizationUrl: 'https://auth.atlassian.com/authorize', - tokenUrl: 'https://auth.atlassian.com/oauth/token', - userInfoUrl: 'https://api.atlassian.com/me', - scopes: getCanonicalScopesForProvider('confluence'), - responseType: 'code', - pkce: true, - accessType: 'offline', - authentication: 'basic', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/confluence`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.atlassian.com/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Confluence user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const profile = await response.json() - - const now = new Date() - - return { - id: `${profile.account_id.toString()}-${generateId()}`, - name: profile.name || profile.display_name || 'Confluence User', - email: profile.email || `${profile.account_id}@atlassian.com`, - image: profile.picture || undefined, - emailVerified: true, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Confluence getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'jira', - clientId: env.JIRA_CLIENT_ID as string, - clientSecret: env.JIRA_CLIENT_SECRET as string, - authorizationUrl: 'https://auth.atlassian.com/authorize', - tokenUrl: 'https://auth.atlassian.com/oauth/token', - userInfoUrl: 'https://api.atlassian.com/me', - scopes: getCanonicalScopesForProvider('jira'), - responseType: 'code', - pkce: true, - accessType: 'offline', - authentication: 'basic', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/jira`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.atlassian.com/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Jira user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const profile = await response.json() - - const now = new Date() - - return { - id: `${profile.account_id.toString()}-${generateId()}`, - name: profile.name || profile.display_name || 'Jira User', - email: profile.email || `${profile.account_id}@atlassian.com`, - image: profile.picture || undefined, - emailVerified: true, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Jira getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'airtable', - clientId: env.AIRTABLE_CLIENT_ID as string, - clientSecret: env.AIRTABLE_CLIENT_SECRET as string, - authorizationUrl: 'https://airtable.com/oauth2/v1/authorize', - tokenUrl: 'https://airtable.com/oauth2/v1/token', - userInfoUrl: 'https://api.airtable.com/v0/meta/whoami', - scopes: getCanonicalScopesForProvider('airtable'), - responseType: 'code', - pkce: true, - accessType: 'offline', - authentication: 'basic', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/airtable`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.airtable.com/v0/meta/whoami', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Airtable user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const data = await response.json() - const now = new Date() - - return { - id: `${data.id.toString()}-${generateId()}`, - name: data.email ? data.email.split('@')[0] : 'Airtable User', - email: data.email || `${data.id}@airtable.user`, - emailVerified: !!data.email, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Airtable getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'notion', - clientId: env.NOTION_CLIENT_ID as string, - clientSecret: env.NOTION_CLIENT_SECRET as string, - authorizationUrl: 'https://api.notion.com/v1/oauth/authorize', - tokenUrl: 'https://api.notion.com/v1/oauth/token', - userInfoUrl: 'https://api.notion.com/v1/users/me', - responseType: 'code', - pkce: false, - accessType: 'offline', - authentication: 'basic', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/notion`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.notion.com/v1/users/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - 'Notion-Version': '2022-06-28', - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Notion user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const profile = await response.json() - const now = new Date() - - return { - id: `${(profile.bot?.owner?.user?.id || profile.id).toString()}-${generateId()}`, - name: profile.name || profile.bot?.owner?.user?.name || 'Notion User', - email: profile.person?.email || `${profile.id}@notion.user`, - emailVerified: !!profile.person?.email, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Notion getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'monday', - clientId: env.MONDAY_CLIENT_ID as string, - clientSecret: env.MONDAY_CLIENT_SECRET as string, - authorizationUrl: 'https://auth.monday.com/oauth2/authorize', - tokenUrl: 'https://auth.monday.com/oauth2/token', - userInfoUrl: 'https://api.monday.com/v2', - scopes: getCanonicalScopesForProvider('monday'), - responseType: 'code', - pkce: false, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/monday`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.monday.com/v2', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'API-Version': '2024-10', - Authorization: tokens.accessToken ?? '', - }, - body: JSON.stringify({ query: '{ me { id name email } }' }), - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Monday.com user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const data = await response.json() - const user = data.data?.me - if (!user) return null - - const now = new Date() - return { - id: `${user.id.toString()}-${generateId()}`, - name: user.name || 'Monday.com User', - email: user.email || `${user.id}@monday.user`, - emailVerified: !!user.email, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Monday.com getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'reddit', - clientId: env.REDDIT_CLIENT_ID as string, - clientSecret: env.REDDIT_CLIENT_SECRET as string, - authorizationUrl: 'https://www.reddit.com/api/v1/authorize?duration=permanent', - tokenUrl: 'https://www.reddit.com/api/v1/access_token', - userInfoUrl: 'https://oauth.reddit.com/api/v1/me', - scopes: getCanonicalScopesForProvider('reddit'), - responseType: 'code', - pkce: false, - accessType: 'offline', - authentication: 'basic', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/reddit`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://oauth.reddit.com/api/v1/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - 'User-Agent': 'sim-studio/1.0', - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Reddit user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const data = await response.json() - const now = new Date() - - return { - id: `${data.id.toString()}-${generateId()}`, - name: data.name || 'Reddit User', - email: `${data.name}@reddit.user`, - image: data.icon_img || undefined, - emailVerified: false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Reddit getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'clickup', - clientId: env.CLICKUP_CLIENT_ID as string, - clientSecret: env.CLICKUP_CLIENT_SECRET as string, - authorizationUrl: 'https://app.clickup.com/api', - tokenUrl: 'https://api.clickup.com/api/v2/oauth/token', - scopes: getCanonicalScopesForProvider('clickup'), - responseType: 'code', - pkce: false, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/clickup`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.clickup.com/api/v2/user', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching ClickUp user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const data = await response.json() - const user = data.user - if (!user?.id) return null - - const now = new Date() - return { - id: `${user.id.toString()}-${generateId()}`, - name: user.username || 'ClickUp User', - email: user.email || `${user.id}@clickup.user`, - emailVerified: !!user.email, - createdAt: now, - updatedAt: now, - image: user.profilePicture || undefined, - } - } catch (error) { - logger.error('Error in ClickUp getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'linear', - clientId: env.LINEAR_CLIENT_ID as string, - clientSecret: env.LINEAR_CLIENT_SECRET as string, - authorizationUrl: 'https://linear.app/oauth/authorize', - tokenUrl: 'https://api.linear.app/oauth/token', - scopes: getCanonicalScopesForProvider('linear'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/linear`, - pkce: true, - prompt: 'consent', - accessType: 'offline', - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.linear.app/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${tokens.accessToken}`, - }, - body: JSON.stringify({ - query: `{ - viewer { - id - email - name - avatarUrl - } - }`, - }), - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error('Linear API error:', { - status: response.status, - statusText: response.statusText, - body: errorText, - }) - throw new Error(`Linear API error: ${response.status} ${response.statusText}`) - } - - const { data, errors } = await response.json() - - if (errors) { - logger.error('GraphQL errors:', errors) - throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`) - } - - if (!data?.viewer) { - logger.error('No viewer data in response:', data) - throw new Error('No viewer data in response') - } - - const viewer = data.viewer - - return { - id: `${viewer.id.toString()}-${generateId()}`, - email: viewer.email, - name: viewer.name, - emailVerified: true, - createdAt: new Date(), - updatedAt: new Date(), - image: viewer.avatarUrl || undefined, - } - } catch (error) { - logger.error('Error in getUserInfo:', error) - throw error - } - }, - }, - - { - providerId: 'attio', - clientId: env.ATTIO_CLIENT_ID as string, - clientSecret: env.ATTIO_CLIENT_SECRET as string, - authorizationUrl: 'https://app.attio.com/authorize', - tokenUrl: 'https://app.attio.com/oauth/token', - scopes: getCanonicalScopesForProvider('attio'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/attio`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.attio.com/v2/workspace_members', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error('Attio API error:', { - status: response.status, - statusText: response.statusText, - body: errorText, - }) - throw new Error(`Attio API error: ${response.status} ${response.statusText}`) - } - - const { data } = await response.json() - - if (!data || data.length === 0) { - throw new Error('No workspace members found in Attio response') - } - - const member = data[0] - - return { - id: `${member.id.workspace_member_id}-${generateId()}`, - email: member.email_address, - name: - `${member.first_name ?? ''} ${member.last_name ?? ''}`.trim() || - member.email_address, - emailVerified: true, - createdAt: new Date(), - updatedAt: new Date(), - image: member.avatar_url || undefined, - } - } catch (error) { - logger.error('Error in Attio getUserInfo:', error) - throw error - } - }, - }, - - { - providerId: 'box', - clientId: env.BOX_CLIENT_ID as string, - clientSecret: env.BOX_CLIENT_SECRET as string, - authorizationUrl: 'https://account.box.com/api/oauth2/authorize', - tokenUrl: 'https://api.box.com/oauth2/token', - scopes: getCanonicalScopesForProvider('box'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/box`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.box.com/2.0/users/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error('Box API error:', { - status: response.status, - statusText: response.statusText, - body: errorText, - }) - throw new Error(`Box API error: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - return { - id: `${data.id}-${generateId()}`, - email: data.login, - name: data.name || data.login, - emailVerified: true, - createdAt: new Date(), - updatedAt: new Date(), - image: data.avatar_url || undefined, - } - } catch (error) { - logger.error('Error in Box getUserInfo:', error) - throw error - } - }, - }, - - { - providerId: 'dropbox', - clientId: env.DROPBOX_CLIENT_ID as string, - clientSecret: env.DROPBOX_CLIENT_SECRET as string, - authorizationUrl: 'https://www.dropbox.com/oauth2/authorize', - tokenUrl: 'https://api.dropboxapi.com/oauth2/token', - scopes: getCanonicalScopesForProvider('dropbox'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/dropbox`, - pkce: true, - accessType: 'offline', - prompt: 'consent', - authorizationUrlParams: { - token_access_type: 'offline', - }, - getUserInfo: async (tokens) => { - try { - const response = await fetch( - 'https://api.dropboxapi.com/2/users/get_current_account', - { - method: 'POST', - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - } - ) - - if (!response.ok) { - const errorText = await response.text() - logger.error('Dropbox API error:', { - status: response.status, - statusText: response.statusText, - body: errorText, - }) - throw new Error(`Dropbox API error: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - return { - id: `${data.account_id.toString()}-${generateId()}`, - email: data.email, - name: data.name?.display_name || data.email, - emailVerified: data.email_verified || false, - createdAt: new Date(), - updatedAt: new Date(), - image: data.profile_photo_url || undefined, - } - } catch (error) { - logger.error('Error in getUserInfo:', error) - throw error - } - }, - }, - - { - providerId: 'asana', - clientId: env.ASANA_CLIENT_ID as string, - clientSecret: env.ASANA_CLIENT_SECRET as string, - authorizationUrl: 'https://app.asana.com/-/oauth_authorize', - tokenUrl: 'https://app.asana.com/-/oauth_token', - userInfoUrl: 'https://app.asana.com/api/1.0/users/me', - scopes: getCanonicalScopesForProvider('asana'), - responseType: 'code', - pkce: false, - accessType: 'offline', - authentication: 'basic', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/asana`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://app.asana.com/api/1.0/users/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Asana user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const result = await response.json() - const profile = result.data - - const now = new Date() - - return { - id: `${profile.gid.toString()}-${generateId()}`, - name: profile.name || 'Asana User', - email: profile.email || `${profile.gid}@asana.user`, - image: profile.photo?.image_128x128 || undefined, - emailVerified: !!profile.email, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Asana getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'slack', - clientId: env.SLACK_CLIENT_ID as string, - clientSecret: env.SLACK_CLIENT_SECRET as string, - authorizationUrl: 'https://slack.com/oauth/v2/authorize', - tokenUrl: 'https://slack.com/api/oauth.v2.access', - userInfoUrl: 'https://slack.com/api/users.identity', - scopes: getCanonicalScopesForProvider('slack'), - responseType: 'code', - accessType: 'offline', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/slack`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://slack.com/api/auth.test', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Slack auth.test failed', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const data = await response.json() - - if (!data.ok) { - logger.error('Slack auth.test returned error', { error: data.error }) - return null - } - - const teamId = data.team_id || 'unknown' - const teamName = data.team || 'Slack Workspace' - - /** - * Tag the accountId with the installing user's Slack id (from the OAuth - * v2 `authed_user.id`, preserved on `tokens.raw`) behind a `usr_` marker. - * The channels selector uses it to scope private-channel visibility to - * the installer's own Slack membership, per Slack Marketplace rules. The - * marker disambiguates it from a legacy bot id (same `U.../B...` shape); - * absent it, we keep the legacy format and today's behavior. - */ - const rawTokens = (tokens as typeof tokens & { raw?: Record }).raw - const authedUser = rawTokens?.authed_user as { id?: string } | undefined - const installerUserId = authedUser?.id - const userSegment = installerUserId - ? `usr_${installerUserId}` - : data.user_id || data.bot_id || 'bot' - - const uniqueId = `${teamId}-${userSegment}` - - logger.info('Slack credential identifier', { - teamId, - userSegment, - uniqueId, - teamName, - hasInstallerId: !!installerUserId, - }) - - return { - id: `${uniqueId}-${generateId()}`, - name: teamName, - email: `${uniqueId}@slack.bot`, - emailVerified: false, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error creating Slack bot profile:', { error }) - return null - } - }, - }, - - { - providerId: 'webflow', - clientId: env.WEBFLOW_CLIENT_ID as string, - clientSecret: env.WEBFLOW_CLIENT_SECRET as string, - authorizationUrl: 'https://webflow.com/oauth/authorize', - tokenUrl: 'https://api.webflow.com/oauth/access_token', - userInfoUrl: 'https://api.webflow.com/v2/token/introspect', - scopes: getCanonicalScopesForProvider('webflow'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/webflow`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching Webflow user info') - - const response = await fetch('https://api.webflow.com/v2/token/introspect', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Webflow user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const data = await response.json() - const now = new Date() - - const userId = data.user_id || 'user' - const uniqueId = `webflow-${userId}` - - return { - id: `${uniqueId}-${generateId()}`, - name: data.user_name || 'Webflow User', - email: `${uniqueId.replace(/[^a-zA-Z0-9]/g, '')}@webflow.user`, - emailVerified: false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Webflow getUserInfo:', { error }) - return null - } - }, - }, - { - providerId: 'linkedin', - clientId: env.LINKEDIN_CLIENT_ID as string, - clientSecret: env.LINKEDIN_CLIENT_SECRET as string, - authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization', - tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken', - userInfoUrl: 'https://api.linkedin.com/v2/userinfo', - scopes: getCanonicalScopesForProvider('linkedin'), - responseType: 'code', - accessType: 'offline', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/linkedin`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching LinkedIn user profile') - - const response = await fetch('https://api.linkedin.com/v2/userinfo', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch LinkedIn user info', { - status: response.status, - statusText: response.statusText, - }) - throw new Error('Failed to fetch user info') - } - - const profile = await response.json() - - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'LinkedIn User', - email: profile.email || `${profile.sub}@linkedin.user`, - emailVerified: profile.email_verified || true, - image: profile.picture || undefined, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error in LinkedIn getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'zoom', - clientId: env.ZOOM_CLIENT_ID as string, - clientSecret: env.ZOOM_CLIENT_SECRET as string, - authorizationUrl: 'https://zoom.us/oauth/authorize', - tokenUrl: 'https://zoom.us/oauth/token', - userInfoUrl: 'https://api.zoom.us/v2/users/me', - scopes: getCanonicalScopesForProvider('zoom'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/zoom`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching Zoom user profile') - - const response = await fetch('https://api.zoom.us/v2/users/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Zoom user info', { - status: response.status, - statusText: response.statusText, - }) - throw new Error('Failed to fetch user info') - } - - const profile = await response.json() - - return { - id: `${profile.id.toString()}-${generateId()}`, - name: - `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Zoom User', - email: profile.email || `${profile.id}@zoom.user`, - emailVerified: profile.verified === 1, - image: profile.pic_url || undefined, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error in Zoom getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'spotify', - clientId: env.SPOTIFY_CLIENT_ID as string, - clientSecret: env.SPOTIFY_CLIENT_SECRET as string, - authorizationUrl: 'https://accounts.spotify.com/authorize', - tokenUrl: 'https://accounts.spotify.com/api/token', - userInfoUrl: 'https://api.spotify.com/v1/me', - scopes: getCanonicalScopesForProvider('spotify'), - responseType: 'code', - authentication: 'basic', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/spotify`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching Spotify user profile') - - const response = await fetch('https://api.spotify.com/v1/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Spotify user info', { - status: response.status, - statusText: response.statusText, - }) - throw new Error('Failed to fetch user info') - } - - const profile = await response.json() - - return { - id: `${profile.id.toString()}-${generateId()}`, - name: profile.display_name || 'Spotify User', - email: profile.email || `${profile.id}@spotify.user`, - emailVerified: true, - image: profile.images?.[0]?.url || undefined, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error in Spotify getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'wordpress', - clientId: env.WORDPRESS_CLIENT_ID as string, - clientSecret: env.WORDPRESS_CLIENT_SECRET as string, - authorizationUrl: 'https://public-api.wordpress.com/oauth2/authorize', - tokenUrl: 'https://public-api.wordpress.com/oauth2/token', - userInfoUrl: 'https://public-api.wordpress.com/rest/v1.1/me', - scopes: getCanonicalScopesForProvider('wordpress'), - responseType: 'code', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wordpress`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching WordPress.com user profile') - - const response = await fetch('https://public-api.wordpress.com/rest/v1.1/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch WordPress.com user info', { - status: response.status, - statusText: response.statusText, - }) - throw new Error('Failed to fetch user info') - } - - const profile = await response.json() - - return { - id: `${profile.ID?.toString() || profile.id?.toString()}-${generateId()}`, - name: profile.display_name || profile.username || 'WordPress User', - email: profile.email || `${profile.username}@wordpress.com`, - emailVerified: profile.email_verified || false, - image: profile.avatar_URL || undefined, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error in WordPress.com getUserInfo:', { error }) - return null - } - }, - }, - - // DocuSign provider - { - providerId: 'docusign', - clientId: env.DOCUSIGN_CLIENT_ID as string, - clientSecret: env.DOCUSIGN_CLIENT_SECRET as string, - authorizationUrl: 'https://account-d.docusign.com/oauth/auth', - tokenUrl: 'https://account-d.docusign.com/oauth/token', - userInfoUrl: 'https://account-d.docusign.com/oauth/userinfo', - scopes: getCanonicalScopesForProvider('docusign'), - responseType: 'code', - accessType: 'offline', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/docusign`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching DocuSign user profile') - - const response = await fetch('https://account-d.docusign.com/oauth/userinfo', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch DocuSign user info', { - status: response.status, - statusText: response.statusText, - }) - throw new Error('Failed to fetch user info') - } - - const data = await response.json() - const accounts = data.accounts ?? [] - const defaultAccount = - accounts.find((a: { is_default: boolean }) => a.is_default) ?? accounts[0] - const accountName = defaultAccount?.account_name || 'DocuSign Account' - - if (data.scope) { - tokens.scopes = data.scope.split(/\s+/).filter(Boolean) - } - - return { - id: `${data.sub}-${generateId()}`, - name: data.name || accountName, - email: data.email || `${data.sub}@docusign.com`, - emailVerified: true, - image: undefined, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error in DocuSign getUserInfo:', { error }) - return null - } - }, - }, - - // Cal.com provider - { - providerId: 'calcom', - clientId: env.CALCOM_CLIENT_ID as string, - authorizationUrl: 'https://app.cal.com/auth/oauth2/authorize', - tokenUrl: 'https://app.cal.com/api/auth/oauth/token', - scopes: getCanonicalScopesForProvider('calcom'), - responseType: 'code', - pkce: true, - accessType: 'offline', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/calcom`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching Cal.com user profile') - - const response = await fetch('https://api.cal.com/v2/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - 'cal-api-version': '2024-08-13', - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Cal.com user info', { - status: response.status, - statusText: response.statusText, - }) - throw new Error('Failed to fetch user info') - } - - const data = await response.json() - const profile = data.data || data - - return { - id: `${profile.id?.toString()}-${generateId()}`, - name: profile.name || 'Cal.com User', - email: profile.email || `${profile.id}@cal.com`, - emailVerified: true, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error in Cal.com getUserInfo:', { error }) - return null - } - }, - }, - ], + config: buildConnectorProviders(), }), /** * Include SSO plugin when enabled. Resolved through `isSsoEnabled` rather diff --git a/apps/sim/lib/auth/connector-email.test.ts b/apps/sim/lib/auth/connector-email.test.ts new file mode 100644 index 00000000000..a9941c70a40 --- /dev/null +++ b/apps/sim/lib/auth/connector-email.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { syntheticConnectorEmail } from '@/lib/auth/connector-email' + +describe('syntheticConnectorEmail', () => { + it('namespaces the address by provider and identity', () => { + expect(syntheticConnectorEmail('attio', 'abc123')).toBe('attio-abc123@connectors.sim.invalid') + }) + + it('always lands on the RFC 2606 reserved .invalid TLD', () => { + const providers: Array<[string, string]> = [ + ['x', 'someuser'], + ['hubspot', '12345'], + ['salesforce', '005xx'], + ['docusign', 'sub-1'], + ['calcom', '77'], + ['atlassian', 'acct'], + ['wordpress', 'blogger'], + ] + for (const [provider, id] of providers) { + const email = syntheticConnectorEmail(provider, id) + expect(email.endsWith('@connectors.sim.invalid')).toBe(true) + } + }) + + it('never emits a live third-party domain', () => { + const email = syntheticConnectorEmail('x', 'jack') + expect(email).not.toMatch(/@(x|hubspot|docusign|cal|salesforce|atlassian|wordpress)\.com$/) + }) + + it('distinguishes the same external id across providers', () => { + expect(syntheticConnectorEmail('zoom', '42')).not.toBe(syntheticConnectorEmail('spotify', '42')) + }) + + it('is deterministic for the same input', () => { + expect(syntheticConnectorEmail('monday', 99)).toBe(syntheticConnectorEmail('monday', 99)) + }) + + it('accepts numeric identifiers', () => { + expect(syntheticConnectorEmail('monday', 99)).toBe('monday-99@connectors.sim.invalid') + }) + + it('strips characters that are illegal in an unquoted local part', () => { + expect(syntheticConnectorEmail('slack', 'T123-usr_U456')).toBe( + 'slack-T123-usr_U456@connectors.sim.invalid' + ) + expect(syntheticConnectorEmail('reddit', 'some user!@#')).toBe( + 'reddit-someuser@connectors.sim.invalid' + ) + }) + + it('keeps the local part inside the RFC 5321 64-character limit', () => { + const email = syntheticConnectorEmail('a'.repeat(100), 'b'.repeat(100)) + const [localPart] = email.split('@') + expect(localPart.length).toBeLessThanOrEqual(64) + }) + + it('does not leave a dot or hyphen at either edge of a truncated segment', () => { + const email = syntheticConnectorEmail('wealthbox', `${'c'.repeat(29)}...tail`) + const [localPart] = email.split('@') + expect(localPart.endsWith('.')).toBe(false) + expect(localPart.startsWith('.')).toBe(false) + }) + + it('falls back to placeholders rather than emitting an empty local part', () => { + expect(syntheticConnectorEmail('notion', undefined)).toBe( + 'notion-unknown@connectors.sim.invalid' + ) + expect(syntheticConnectorEmail('notion', '')).toBe('notion-unknown@connectors.sim.invalid') + expect(syntheticConnectorEmail('', '')).toBe('connector-unknown@connectors.sim.invalid') + expect(syntheticConnectorEmail('!!!', '###')).toBe('connector-unknown@connectors.sim.invalid') + }) + + it('always returns a truthy value, which is what Better Auth 1.6.23 requires', () => { + expect(syntheticConnectorEmail('', undefined)).toBeTruthy() + }) +}) diff --git a/apps/sim/lib/auth/connector-email.ts b/apps/sim/lib/auth/connector-email.ts new file mode 100644 index 00000000000..6f9725a61fe --- /dev/null +++ b/apps/sim/lib/auth/connector-email.ts @@ -0,0 +1,61 @@ +/** RFC 2606 §2 reserved TLD — permanently unregistrable and unroutable. */ +const SYNTHETIC_EMAIL_DOMAIN = 'connectors.sim.invalid' + +/** Longest local-part segment kept, so the address stays under the 64-char RFC 5321 limit. */ +const MAX_SEGMENT_LENGTH = 30 + +/** + * Reduce an arbitrary upstream identifier to characters that are unambiguously + * legal in an unquoted email local part. + */ +function sanitizeLocalPart(value: string): string { + return ( + value + .replace(/[^a-zA-Z0-9._-]/g, '') + .slice(0, MAX_SEGMENT_LENGTH) + // RFC 5321 `dot-string` is `Atom *("." Atom)`, so a run of separators is not + // a legal local part — and stripping illegal characters readily creates one. + .replace(/[._-]{2,}/g, '-') + .replace(/^[._-]+|[._-]+$/g, '') + ) +} + +/** + * Synthetic placeholder email for an OAuth connector identity. + * + * Many connector providers either never expose an email (X, Slack bot tokens, + * TikTok, Reddit, Webflow) or expose one only when an optional scope was + * granted. Better Auth still demands one: in `better-auth@1.6.23`, + * `dist/plugins/generic-oauth/routes.mjs` hard-rejects a falsy `email` returned + * from `getUserInfo` by throwing a redirect to `?error=email_is_missing`. There + * is no option to disable that guard, so every `getUserInfo` must return a + * truthy address or the connect flow dies at the callback. + * + * The value is never persisted. Sim's connectors go through the session-bound + * `oauth2.link` path, the `account` table has no email column, and + * `updateUserInfoOnLink` is unset — so Better Auth reads the address, satisfies + * its own guard, and discards it. It is never shown to a user, never mailed to, + * and never matched against a real account. + * + * The domain is `.invalid`, reserved by RFC 2606 §2 precisely so that it can + * never be registered or routed. Earlier code synthesized addresses on live + * third-party domains (`@x.com`, `@salesforce.com`, `@atlassian.com`, …), which + * are owned by other companies and could in principle resolve to a real + * mailbox. + * + * Delete this helper and return the upstream email directly once Better Auth + * relaxes the guard (tracked in better-auth issue #9124, slated for v2). + * + * @param providerId - Connector provider id, e.g. `'attio'`. Namespaces the + * address so two providers reporting the same external id do not collide. + * @param stableId - Stable external identifier for the connected identity + * (workspace member id, account id, username, …). Falsy or fully-unsupported + * values degrade to `unknown`; uniqueness is best-effort because the address + * is discarded either way. + * @returns An RFC 5321-shaped address on a permanently unroutable domain. + */ +export function syntheticConnectorEmail(providerId: string, stableId?: string | number): string { + const provider = sanitizeLocalPart(providerId) || 'connector' + const identity = sanitizeLocalPart(stableId == null ? '' : String(stableId)) || 'unknown' + return `${provider}-${identity}@${SYNTHETIC_EMAIL_DOMAIN}` +} diff --git a/apps/sim/lib/auth/connectors/providers.ts b/apps/sim/lib/auth/connectors/providers.ts new file mode 100644 index 00000000000..af3a1928354 --- /dev/null +++ b/apps/sim/lib/auth/connectors/providers.ts @@ -0,0 +1,2410 @@ +import { createHash } from 'crypto' +import { getOAuth2Tokens } from '@better-auth/core/oauth2' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { GenericOAuthConfig } from 'better-auth/plugins' +import { syntheticConnectorEmail } from '@/lib/auth/connector-email' +import { env } from '@/lib/core/config/env' +import { + readResponseJsonWithLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { getMicrosoftUserInfoFromIdToken } from '@/lib/oauth/microsoft' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { deriveZohoDeskBaseFromApiDomain } from '@/tools/zoho_desk/host-allowlist' + +/** + * Third-party connector definitions for Better Auth's `genericOAuth` plugin. + * + * These are the OAuth apps a workspace connects *tools* to — Gmail, Jira, + * Slack and the rest — as distinct from the handful of providers used to sign + * in to Sim itself, which stay in `socialProviders` in `lib/auth/auth.ts`. + * + * They live here rather than in `auth.ts` because each entry carries real + * per-provider logic — a `getUserInfo` fetch, its response shape, and its error + * handling — and in aggregate that buried the auth configuration itself. + */ + +/** + * Scoped `'Auth'` rather than something module-specific: these log lines + * predate this file, and renaming the scope would silently break every existing + * log query and alert that matches on it. + */ +const logger = createLogger('Auth') + +/** + * Shape of `GET https://api.notion.com/v1/users/me` for an OAuth integration token. + * @see https://developers.notion.com/reference/get-self + */ +interface NotionSelfResponse { + id: string + name?: string | null + bot?: { + owner?: + | { type: 'user'; user?: { id: string; name?: string | null; person?: { email?: string } } } + | { type: 'workspace'; workspace: true } + } +} + +/** + * Shape of `GET https://api.attio.com/v2/self` (the Identify endpoint). + * @see https://docs.attio.com/rest-api/endpoint-reference/meta/identify + */ +interface AttioSelfResponse { + active?: boolean + authorized_by_workspace_member_id?: string | null + workspace_id?: string + workspace_name?: string +} + +/** + * Shape of `GET https://api.attio.com/v2/workspace_members/{id}`. + * @see https://docs.attio.com/rest-api/endpoint-reference/workspace-members/get-a-workspace-member + */ +interface AttioWorkspaceMemberResponse { + data?: { + id: { workspace_id: string; workspace_member_id: string } + first_name?: string | null + last_name?: string | null + email_address?: string | null + avatar_url?: string | null + } +} + +/** + * Builds the connector list, evaluated once when `betterAuth()` constructs the + * auth instance — the same point the array was built at when it was inline. + * + * A function rather than a module-level constant so that importing this module + * never on its own requires a configured environment: the entries call + * `getBaseUrl()`, which throws when `NEXT_PUBLIC_APP_URL` is unset. That keeps + * the module importable in isolation, by a unit test or a script enumerating + * provider ids, without booting the whole auth configuration. + * + * The explicit `GenericOAuthConfig[]` return type is load-bearing: inline, the + * entries were contextually typed by the `config` property they were assigned + * to. Without the annotation the literals widen (`prompt: string` stops + * matching its union) and every `getUserInfo` parameter becomes implicitly + * `any`. + */ +export function buildConnectorProviders(): GenericOAuthConfig[] { + return [ + { + providerId: 'google-email', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-email'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-email`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-calendar', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-calendar'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-calendar`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-drive', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-drive'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-drive`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-docs', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-docs'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-docs`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-sheets', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-sheets'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-sheets`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + + { + providerId: 'google-contacts', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-contacts'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-contacts`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-forms', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-forms'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-forms`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-ads', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-ads'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-ads`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-bigquery', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-bigquery'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-bigquery`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + + { + providerId: 'google-vault', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-vault'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-vault`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + + { + providerId: 'google-groups', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-groups'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-groups`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + + { + providerId: 'google-meet', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-meet'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-meet`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-tasks', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-tasks'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-tasks`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + + { + providerId: 'vertex-ai', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('vertex-ai'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/vertex-ai`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + + { + providerId: 'microsoft-ad', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('microsoft-ad'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-ad`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-ad') + }, + }, + + { + providerId: 'microsoft-teams', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('microsoft-teams'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-teams`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-teams') + }, + }, + + { + providerId: 'microsoft-excel', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('microsoft-excel'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-excel`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-excel') + }, + }, + { + providerId: 'microsoft-dataverse', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('microsoft-dataverse'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-dataverse`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-dataverse') + }, + }, + { + providerId: 'microsoft-planner', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('microsoft-planner'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-planner`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-planner') + }, + }, + + { + providerId: 'outlook', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('outlook'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/outlook`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'outlook') + }, + }, + + { + providerId: 'onedrive', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('onedrive'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/onedrive`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'onedrive') + }, + }, + + { + providerId: 'sharepoint', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('sharepoint'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/sharepoint`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'sharepoint') + }, + }, + + { + providerId: 'wealthbox', + clientId: env.WEALTHBOX_CLIENT_ID as string, + clientSecret: env.WEALTHBOX_CLIENT_SECRET as string, + authorizationUrl: 'https://app.crmworkspace.com/oauth/authorize', + tokenUrl: 'https://app.crmworkspace.com/oauth/token', + userInfoUrl: 'https://api.crmworkspace.com/v1/me', + scopes: getCanonicalScopesForProvider('wealthbox'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wealthbox`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching Wealthbox user profile') + + const response = await fetch('https://api.crmworkspace.com/v1/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + const now = new Date() + + if (response.ok) { + const data = await response.json() + const userId = data.id?.toString() + if (!userId) { + return null + } + const email = + data.email && typeof data.email === 'string' + ? data.email + : syntheticConnectorEmail('wealthbox', userId) + const name = data.name || data.full_name || data.username || 'Wealthbox User' + + return { + id: `wealthbox-${userId}-${generateId()}`, + name, + email, + emailVerified: false, + createdAt: now, + updatedAt: now, + } + } + + // Fallback: derive a stable identifier from the refresh token (long-lived) + // rather than the access token (rotates every ~2 hours) to avoid creating + // duplicate accounts on token refresh. + logger.warn('Wealthbox user info fetch failed, falling back to token-derived identity', { + status: response.status, + }) + const stableToken = tokens.refreshToken ?? tokens.accessToken + if (!stableToken) { + logger.error('Wealthbox fallback identity: no refresh or access token available') + return null + } + const tokenHash = createHash('sha256').update(stableToken).digest('hex').slice(0, 24) + return { + id: `wealthbox-${tokenHash}-${generateId()}`, + name: 'Wealthbox User', + email: syntheticConnectorEmail('wealthbox', tokenHash), + emailVerified: false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error creating Wealthbox user profile:', { + error: toError(error).message, + }) + return null + } + }, + }, + + { + providerId: 'pipedrive', + clientId: env.PIPEDRIVE_CLIENT_ID as string, + clientSecret: env.PIPEDRIVE_CLIENT_SECRET as string, + authorizationUrl: 'https://oauth.pipedrive.com/oauth/authorize', + tokenUrl: 'https://oauth.pipedrive.com/oauth/token', + userInfoUrl: 'https://api.pipedrive.com/v1/users/me', + prompt: 'consent', + scopes: getCanonicalScopesForProvider('pipedrive'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/pipedrive`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching Pipedrive user profile') + + const response = await fetch('https://api.pipedrive.com/v1/users/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Pipedrive user info', { + status: response.status, + }) + throw new Error('Failed to fetch user info') + } + + const data = await response.json() + const user = data.data + + return { + id: `${user.id.toString()}-${generateId()}`, + name: user.name, + email: user.email, + emailVerified: user.activated, + image: user.icon_url, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error creating Pipedrive user profile:', { error }) + return null + } + }, + }, + + { + providerId: 'hubspot', + clientId: env.HUBSPOT_CLIENT_ID as string, + clientSecret: env.HUBSPOT_CLIENT_SECRET as string, + authorizationUrl: 'https://app.hubspot.com/oauth/authorize', + tokenUrl: 'https://api.hubapi.com/oauth/v1/token', + userInfoUrl: 'https://api.hubapi.com/oauth/v1/access-tokens', + prompt: 'consent', + scopes: getCanonicalScopesForProvider('hubspot'), + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/hubspot`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching HubSpot user profile') + + const response = await fetch( + `https://api.hubapi.com/oauth/v1/access-tokens/${tokens.accessToken}` + ) + + if (!response.ok) { + let errorBody: string | undefined + try { + errorBody = await response.text() + } catch { + // ignore + } + logger.error('Failed to fetch HubSpot user info', { + status: response.status, + statusText: response.statusText, + body: errorBody?.slice(0, 500), + }) + throw new Error('Failed to fetch user info') + } + + const rawText = await response.text() + const data = JSON.parse(rawText) + + const scopesArray = Array.isArray((data as any)?.scopes) ? (data as any).scopes : [] + if (Array.isArray(scopesArray) && scopesArray.length > 0) { + tokens.scopes = scopesArray + } else if (typeof (data as any)?.scope === 'string') { + tokens.scopes = (data as any).scope.split(/\s+/).filter(Boolean) + } + + logger.info('HubSpot token metadata response:', { + hubId: data.hub_id, + hubDomain: data.hub_domain, + userId: data.user_id, + hasScopes: !!data.scopes, + scopesType: typeof data.scopes, + scopesIsArray: Array.isArray(data.scopes), + }) + + return { + id: `${(data.user_id || data.hub_id).toString()}-${generateId()}`, + name: data.user || 'HubSpot User', + email: data.user || syntheticConnectorEmail('hubspot', data.hub_id), + emailVerified: true, + image: undefined, + createdAt: new Date(), + updatedAt: new Date(), + // Extract scopes from HubSpot's response and convert array to space-delimited string + // Use 'scope' (singular) as that's what better-auth expects for the account table + ...(data.scopes && Array.isArray(data.scopes) ? { scope: data.scopes.join(' ') } : {}), + } + } catch (error) { + logger.error('Error creating HubSpot user profile:', { error }) + return null + } + }, + }, + + { + providerId: 'salesforce', + clientId: env.SALESFORCE_CLIENT_ID as string, + clientSecret: env.SALESFORCE_CLIENT_SECRET as string, + authorizationUrl: 'https://login.salesforce.com/services/oauth2/authorize', + tokenUrl: 'https://login.salesforce.com/services/oauth2/token', + userInfoUrl: 'https://login.salesforce.com/services/oauth2/userinfo', + scopes: getCanonicalScopesForProvider('salesforce'), + pkce: true, + prompt: 'consent', + accessType: 'offline', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/salesforce`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://login.salesforce.com/services/oauth2/userinfo', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Salesforce user info', { + status: response.status, + }) + throw new Error('Failed to fetch user info') + } + + const data = await response.json() + + return { + id: `${(data.user_id || data.sub).toString()}-${generateId()}`, + name: data.name || 'Salesforce User', + email: data.email || syntheticConnectorEmail('salesforce', data.user_id ?? data.sub), + emailVerified: data.email_verified === true, + image: data.picture || undefined, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error creating Salesforce user profile:', { error }) + return null + } + }, + }, + + { + providerId: 'zoho-desk', + clientId: env.ZOHO_CLIENT_ID as string, + clientSecret: env.ZOHO_CLIENT_SECRET as string, + authorizationUrl: 'https://accounts.zoho.com/oauth/v2/auth', + tokenUrl: 'https://accounts.zoho.com/oauth/v2/token', + scopes: getCanonicalScopesForProvider('zoho-desk'), + responseType: 'code', + pkce: true, + accessType: 'offline', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/zoho-desk`, + // Zoho only issues a refresh token when access_type=offline AND + // prompt=consent are present on the authorize request, and it expects + // comma-separated scopes rather than the default space-delimited list. + authorizationUrlParams: { + access_type: 'offline', + prompt: 'consent', + scope: getCanonicalScopesForProvider('zoho-desk').join(','), + }, + getToken: async ({ code, redirectURI, codeVerifier }) => { + const tokenParams = new URLSearchParams({ + client_id: env.ZOHO_CLIENT_ID as string, + client_secret: env.ZOHO_CLIENT_SECRET as string, + code, + grant_type: 'authorization_code', + redirect_uri: redirectURI, + }) + // PKCE is enabled, so better-auth sent a code_challenge on the authorize + // request. The exchange MUST echo the matching code_verifier or Zoho + // rejects the request shape (invalid_request). Verified by isolating + // pkce:false (which connected) then restoring pkce:true + this verifier. + if (codeVerifier) tokenParams.set('code_verifier', codeVerifier) + + const response = await fetch('https://accounts.zoho.com/oauth/v2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenParams, + }) + const data = await readResponseJsonWithLimit>(response, { + maxBytes: 1024 * 1024, + label: 'Zoho Desk OAuth token response', + }) + + // Zoho signals OAuth failures in the JSON body, usually with HTTP 200, + // e.g. { error: 'invalid_code' } or { error: 'invalid_client', + // error_description: '...' }. The status-only guard therefore never + // fires, so surface the actual error/description instead of collapsing + // every failure into one opaque "no access token" string. + const errorObj = + data && typeof data === 'object' && !Array.isArray(data) + ? (data as { error?: unknown; error_description?: unknown }) + : {} + const zohoError = typeof errorObj.error === 'string' ? errorObj.error : undefined + const zohoErrorDescription = + typeof errorObj.error_description === 'string' ? errorObj.error_description : undefined + if (!response.ok || !data || typeof data !== 'object' || Array.isArray(data) || zohoError) { + logger.error('Zoho Desk OAuth token exchange failed', { + status: response.status, + zohoError: zohoError ?? null, + zohoErrorDescription: zohoErrorDescription ?? null, + }) + throw new Error( + `Zoho Desk OAuth token exchange failed (HTTP ${response.status}${ + zohoError ? `, ${zohoError}` : '' + }${zohoErrorDescription ? `: ${zohoErrorDescription}` : ''})` + ) + } + + const tokens = getOAuth2Tokens(data) + if (!tokens.accessToken) { + logger.error('Zoho Desk OAuth token response had no access token', { + status: response.status, + bodyKeys: Object.keys(data), + }) + throw new Error('Zoho Desk OAuth token response did not include an access token') + } + + // Persist the data-center-scoped Desk REST base derived from the + // token response api_domain so every API call targets the correct + // host instead of assuming desk.zoho.com. Stored inside the scope + // string (survives refreshes, which never rewrite scope) and read + // back in /api/auth/oauth/token as `apiDomain`. + const deskBase = deriveZohoDeskBaseFromApiDomain( + typeof data.api_domain === 'string' ? data.api_domain : undefined + ) + // Zoho's docs are inconsistent about whether the Desk token response + // carries `scope` (the Mail sample has it; the CRM/Creator samples do + // not). If it is absent, fall back to the scopes we requested and were + // granted by completing the flow - otherwise the stored scope list is + // just the domain marker, and the credential picker would show a + // permanent "needs update / reconnect" badge on every connection. + // Mirrors the existing Box fallback in this file. + const reportedScopes = + typeof data.scope === 'string' ? data.scope.split(/[\s,]+/).filter(Boolean) : [] + const grantedScopes = reportedScopes.length + ? reportedScopes + : getCanonicalScopesForProvider('zoho-desk') + tokens.scopes = [`__zoho_domain__:${deskBase}`, ...grantedScopes] + return tokens + }, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://accounts.zoho.com/oauth/user/info', { + headers: { Authorization: `Zoho-oauthtoken ${tokens.accessToken}` }, + }) + + if (!response.ok) { + await readResponseTextWithLimit(response, { + maxBytes: 1024 * 1024, + label: 'Zoho Desk profile error response', + }).catch(() => {}) + logger.error('Error fetching Zoho Desk user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile = await readResponseJsonWithLimit<{ + ZUID?: number | string + Display_Name?: string + Email?: string + }>(response, { maxBytes: 1024 * 1024, label: 'Zoho Desk profile response' }) + + const zuid = profile.ZUID?.toString() + if (!zuid) { + logger.error('Invalid Zoho Desk profile response:', profile) + return null + } + + const now = new Date() + return { + id: `${zuid}-${generateId()}`, + name: profile.Display_Name || 'Zoho User', + email: profile.Email || syntheticConnectorEmail('zoho', zuid), + emailVerified: Boolean(profile.Email), + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Zoho Desk getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'x', + clientId: env.X_CLIENT_ID as string, + clientSecret: env.X_CLIENT_SECRET as string, + authorizationUrl: 'https://x.com/i/oauth2/authorize', + tokenUrl: 'https://api.x.com/2/oauth2/token', + userInfoUrl: 'https://api.x.com/2/users/me', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('x'), + pkce: true, + responseType: 'code', + prompt: 'consent', + authentication: 'basic', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/x`, + getUserInfo: async (tokens) => { + try { + const response = await fetch( + 'https://api.x.com/2/users/me?user.fields=profile_image_url,username,name,verified', + { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + } + ) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching X user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile = await response.json() + + if (!profile.data) { + logger.error('Invalid X profile response:', profile) + return null + } + + const now = new Date() + + return { + id: `${profile.data.id.toString()}-${generateId()}`, + name: profile.data.name || 'X User', + email: syntheticConnectorEmail('x', profile.data.username ?? profile.data.id), + image: profile.data.profile_image_url, + emailVerified: profile.data.verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in X getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'tiktok', + clientId: env.TIKTOK_CLIENT_ID as string, + clientSecret: env.TIKTOK_CLIENT_SECRET as string, + authorizationUrl: 'https://www.tiktok.com/v2/auth/authorize/', + tokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/', + scopes: getCanonicalScopesForProvider('tiktok'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/tiktok`, + authorizationUrlParams: { + client_key: env.TIKTOK_CLIENT_ID as string, + scope: getCanonicalScopesForProvider('tiktok').join(','), + }, + getToken: async ({ code, redirectURI }) => { + const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_key: env.TIKTOK_CLIENT_ID as string, + client_secret: env.TIKTOK_CLIENT_SECRET as string, + code, + grant_type: 'authorization_code', + redirect_uri: redirectURI, + }), + }) + const data = await readResponseJsonWithLimit>(response, { + maxBytes: 1024 * 1024, + label: 'TikTok OAuth token response', + }) + + if (!response.ok || !data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error(`TikTok OAuth token exchange failed with HTTP ${response.status}`) + } + + const tokens = getOAuth2Tokens(data) + if (!tokens.accessToken) { + throw new Error('TikTok OAuth token response did not include an access token') + } + if (typeof data.scope === 'string') { + tokens.scopes = data.scope.split(/[\s,]+/).filter(Boolean) + } + return tokens + }, + getUserInfo: async (tokens) => { + try { + const response = await fetch( + 'https://open.tiktokapis.com/v2/user/info/?fields=open_id,display_name,avatar_url', + { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + } + ) + + if (!response.ok) { + await readResponseTextWithLimit(response, { + maxBytes: 1024 * 1024, + label: 'TikTok profile error response', + }).catch(() => {}) + logger.error('Error fetching TikTok user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile = await readResponseJsonWithLimit<{ + data?: { + user?: { + avatar_url?: string + display_name?: string + open_id?: string + } + } + }>(response, { + maxBytes: 1024 * 1024, + label: 'TikTok profile response', + }) + const user = profile.data?.user + + if (!user?.open_id) { + logger.error('Invalid TikTok profile response:', profile) + return null + } + + const now = new Date() + + return { + id: `${user.open_id}-${generateId()}`, + name: user.display_name || 'TikTok User', + email: syntheticConnectorEmail('tiktok', user.open_id), + image: user.avatar_url || undefined, + emailVerified: false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in TikTok getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'confluence', + clientId: env.CONFLUENCE_CLIENT_ID as string, + clientSecret: env.CONFLUENCE_CLIENT_SECRET as string, + authorizationUrl: 'https://auth.atlassian.com/authorize', + tokenUrl: 'https://auth.atlassian.com/oauth/token', + userInfoUrl: 'https://api.atlassian.com/me', + scopes: getCanonicalScopesForProvider('confluence'), + responseType: 'code', + pkce: true, + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/confluence`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.atlassian.com/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Confluence user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile = await response.json() + + const now = new Date() + + return { + id: `${profile.account_id.toString()}-${generateId()}`, + name: profile.name || profile.display_name || 'Confluence User', + email: profile.email || syntheticConnectorEmail('confluence', profile.account_id), + image: profile.picture || undefined, + emailVerified: true, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Confluence getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'jira', + clientId: env.JIRA_CLIENT_ID as string, + clientSecret: env.JIRA_CLIENT_SECRET as string, + authorizationUrl: 'https://auth.atlassian.com/authorize', + tokenUrl: 'https://auth.atlassian.com/oauth/token', + userInfoUrl: 'https://api.atlassian.com/me', + scopes: getCanonicalScopesForProvider('jira'), + responseType: 'code', + pkce: true, + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/jira`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.atlassian.com/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Jira user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile = await response.json() + + const now = new Date() + + return { + id: `${profile.account_id.toString()}-${generateId()}`, + name: profile.name || profile.display_name || 'Jira User', + email: profile.email || syntheticConnectorEmail('jira', profile.account_id), + image: profile.picture || undefined, + emailVerified: true, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Jira getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'airtable', + clientId: env.AIRTABLE_CLIENT_ID as string, + clientSecret: env.AIRTABLE_CLIENT_SECRET as string, + authorizationUrl: 'https://airtable.com/oauth2/v1/authorize', + tokenUrl: 'https://airtable.com/oauth2/v1/token', + userInfoUrl: 'https://api.airtable.com/v0/meta/whoami', + scopes: getCanonicalScopesForProvider('airtable'), + responseType: 'code', + pkce: true, + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/airtable`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.airtable.com/v0/meta/whoami', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Airtable user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + const now = new Date() + + return { + id: `${data.id.toString()}-${generateId()}`, + name: data.email ? data.email.split('@')[0] : 'Airtable User', + email: data.email || syntheticConnectorEmail('airtable', data.id), + emailVerified: !!data.email, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Airtable getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'notion', + clientId: env.NOTION_CLIENT_ID as string, + clientSecret: env.NOTION_CLIENT_SECRET as string, + authorizationUrl: 'https://api.notion.com/v1/oauth/authorize', + tokenUrl: 'https://api.notion.com/v1/oauth/token', + userInfoUrl: 'https://api.notion.com/v1/users/me', + responseType: 'code', + pkce: false, + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/notion`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.notion.com/v1/users/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + 'Notion-Version': '2022-06-28', + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Notion user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile: NotionSelfResponse = await response.json() + const now = new Date() + + /** + * An OAuth integration token always resolves to a bot user, so the + * top-level `person` is never present and the top-level `name` is the + * integration's own name ("Sim"), not the human's. The authorizing + * human — and their email — live under `bot.owner.user`, which is + * only populated when `bot.owner.type === 'user'` (a workspace-owned + * internal integration reports `{ type: 'workspace' }` instead). + * @see https://developers.notion.com/reference/get-self + */ + const ownerUser = profile.bot?.owner?.type === 'user' ? profile.bot.owner.user : null + const stableId = ownerUser?.id || profile.id + const ownerEmail = ownerUser?.person?.email + + return { + id: `${stableId}-${generateId()}`, + name: ownerUser?.name || profile.name || 'Notion User', + email: ownerEmail || syntheticConnectorEmail('notion', stableId), + emailVerified: !!ownerEmail, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Notion getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'monday', + clientId: env.MONDAY_CLIENT_ID as string, + clientSecret: env.MONDAY_CLIENT_SECRET as string, + authorizationUrl: 'https://auth.monday.com/oauth2/authorize', + tokenUrl: 'https://auth.monday.com/oauth2/token', + userInfoUrl: 'https://api.monday.com/v2', + scopes: getCanonicalScopesForProvider('monday'), + responseType: 'code', + pkce: false, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/monday`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.monday.com/v2', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'API-Version': '2024-10', + Authorization: tokens.accessToken ?? '', + }, + body: JSON.stringify({ query: '{ me { id name email } }' }), + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Monday.com user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + const user = data.data?.me + if (!user) return null + + const now = new Date() + return { + id: `${user.id.toString()}-${generateId()}`, + name: user.name || 'Monday.com User', + email: user.email || syntheticConnectorEmail('monday', user.id), + emailVerified: !!user.email, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Monday.com getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'reddit', + clientId: env.REDDIT_CLIENT_ID as string, + clientSecret: env.REDDIT_CLIENT_SECRET as string, + authorizationUrl: 'https://www.reddit.com/api/v1/authorize?duration=permanent', + tokenUrl: 'https://www.reddit.com/api/v1/access_token', + userInfoUrl: 'https://oauth.reddit.com/api/v1/me', + scopes: getCanonicalScopesForProvider('reddit'), + responseType: 'code', + pkce: false, + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/reddit`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://oauth.reddit.com/api/v1/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + 'User-Agent': 'sim-studio/1.0', + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Reddit user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + const now = new Date() + + return { + id: `${data.id.toString()}-${generateId()}`, + name: data.name || 'Reddit User', + email: syntheticConnectorEmail('reddit', data.name ?? data.id), + image: data.icon_img || undefined, + emailVerified: false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Reddit getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'clickup', + clientId: env.CLICKUP_CLIENT_ID as string, + clientSecret: env.CLICKUP_CLIENT_SECRET as string, + authorizationUrl: 'https://app.clickup.com/api', + tokenUrl: 'https://api.clickup.com/api/v2/oauth/token', + scopes: getCanonicalScopesForProvider('clickup'), + responseType: 'code', + pkce: false, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/clickup`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.clickup.com/api/v2/user', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching ClickUp user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + const user = data.user + if (!user?.id) return null + + const now = new Date() + return { + id: `${user.id.toString()}-${generateId()}`, + name: user.username || 'ClickUp User', + email: user.email || syntheticConnectorEmail('clickup', user.id), + emailVerified: !!user.email, + createdAt: now, + updatedAt: now, + image: user.profilePicture || undefined, + } + } catch (error) { + logger.error('Error in ClickUp getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'linear', + clientId: env.LINEAR_CLIENT_ID as string, + clientSecret: env.LINEAR_CLIENT_SECRET as string, + authorizationUrl: 'https://linear.app/oauth/authorize', + tokenUrl: 'https://api.linear.app/oauth/token', + scopes: getCanonicalScopesForProvider('linear'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/linear`, + pkce: true, + prompt: 'consent', + accessType: 'offline', + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${tokens.accessToken}`, + }, + body: JSON.stringify({ + query: `{ + viewer { + id + email + name + avatarUrl + } + }`, + }), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Linear API error:', { + status: response.status, + statusText: response.statusText, + body: errorText, + }) + throw new Error(`Linear API error: ${response.status} ${response.statusText}`) + } + + const { data, errors } = await response.json() + + if (errors) { + logger.error('GraphQL errors:', errors) + throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`) + } + + if (!data?.viewer) { + logger.error('No viewer data in response:', data) + throw new Error('No viewer data in response') + } + + const viewer = data.viewer + + return { + id: `${viewer.id.toString()}-${generateId()}`, + email: viewer.email || syntheticConnectorEmail('linear', viewer.id), + name: viewer.name, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + image: viewer.avatarUrl || undefined, + } + } catch (error) { + logger.error('Error in getUserInfo:', error) + throw error + } + }, + }, + + { + providerId: 'attio', + clientId: env.ATTIO_CLIENT_ID as string, + clientSecret: env.ATTIO_CLIENT_SECRET as string, + authorizationUrl: 'https://app.attio.com/authorize', + tokenUrl: 'https://app.attio.com/oauth/token', + scopes: getCanonicalScopesForProvider('attio'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/attio`, + getUserInfo: async (tokens) => { + try { + /** + * Resolve the *authorizing* member, not an arbitrary one. Listing + * `/v2/workspace_members` returns every member of the workspace in no + * defined order, so taking `data[0]` records a stranger's id as the + * account's stable external id — which then collapses two different + * Attio members into one account row via the stale-sibling dedupe in + * the `account.create.after` hook. + * + * `/v2/self` requires no scope and reports who authorized the token. + * @see https://docs.attio.com/rest-api/endpoint-reference/meta/identify + */ + const selfResponse = await fetch('https://api.attio.com/v2/self', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + + if (!selfResponse.ok) { + const errorText = await selfResponse.text().catch(() => '') + logger.error('Attio /v2/self error:', { + status: selfResponse.status, + statusText: selfResponse.statusText, + body: errorText, + }) + return null + } + + const self: AttioSelfResponse = await selfResponse.json() + const memberId = self.authorized_by_workspace_member_id + + if (!memberId) { + logger.error('Attio /v2/self returned no authorizing workspace member', { + active: self.active, + workspaceId: self.workspace_id, + }) + return null + } + + /** + * Fetch that member by id rather than listing and filtering. Requires + * `user_management:read`, which Sim always requests for Attio. + * @see https://docs.attio.com/rest-api/endpoint-reference/workspace-members/get-a-workspace-member + */ + const memberResponse = await fetch( + `https://api.attio.com/v2/workspace_members/${encodeURIComponent(memberId)}`, + { headers: { Authorization: `Bearer ${tokens.accessToken}` } } + ) + + if (!memberResponse.ok) { + const errorText = await memberResponse.text().catch(() => '') + logger.error('Attio workspace member fetch error:', { + status: memberResponse.status, + statusText: memberResponse.statusText, + body: errorText, + }) + return null + } + + const { data: member }: AttioWorkspaceMemberResponse = await memberResponse.json() + + if (!member) { + logger.error('Attio workspace member not found', { memberId }) + return null + } + + const email = member.email_address + const fullName = `${member.first_name ?? ''} ${member.last_name ?? ''}`.trim() + + return { + id: `${member.id.workspace_member_id}-${generateId()}`, + email: email || syntheticConnectorEmail('attio', member.id.workspace_member_id), + name: fullName || email || 'Attio User', + emailVerified: Boolean(email), + createdAt: new Date(), + updatedAt: new Date(), + image: member.avatar_url || undefined, + } + } catch (error) { + /** + * Return null rather than rethrowing: Better Auth's `handleUserInfo` + * does not wrap `getUserInfo`, so a throw escapes the callback route + * as a raw 500 with no way back into the app, while null redirects + * with `user_info_is_missing`. + */ + logger.error('Error in Attio getUserInfo:', error) + return null + } + }, + }, + + { + providerId: 'box', + clientId: env.BOX_CLIENT_ID as string, + clientSecret: env.BOX_CLIENT_SECRET as string, + authorizationUrl: 'https://account.box.com/api/oauth2/authorize', + tokenUrl: 'https://api.box.com/oauth2/token', + scopes: getCanonicalScopesForProvider('box'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/box`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.box.com/2.0/users/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Box API error:', { + status: response.status, + statusText: response.statusText, + body: errorText, + }) + throw new Error(`Box API error: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + + return { + id: `${data.id}-${generateId()}`, + email: data.login || syntheticConnectorEmail('box', data.id), + name: data.name || data.login || 'Box User', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + image: data.avatar_url || undefined, + } + } catch (error) { + logger.error('Error in Box getUserInfo:', error) + throw error + } + }, + }, + + { + providerId: 'dropbox', + clientId: env.DROPBOX_CLIENT_ID as string, + clientSecret: env.DROPBOX_CLIENT_SECRET as string, + authorizationUrl: 'https://www.dropbox.com/oauth2/authorize', + tokenUrl: 'https://api.dropboxapi.com/oauth2/token', + scopes: getCanonicalScopesForProvider('dropbox'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/dropbox`, + pkce: true, + accessType: 'offline', + prompt: 'consent', + authorizationUrlParams: { + token_access_type: 'offline', + }, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.dropboxapi.com/2/users/get_current_account', { + method: 'POST', + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Dropbox API error:', { + status: response.status, + statusText: response.statusText, + body: errorText, + }) + throw new Error(`Dropbox API error: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + + return { + id: `${data.account_id.toString()}-${generateId()}`, + email: data.email, + name: data.name?.display_name || data.email, + emailVerified: data.email_verified || false, + createdAt: new Date(), + updatedAt: new Date(), + image: data.profile_photo_url || undefined, + } + } catch (error) { + logger.error('Error in getUserInfo:', error) + throw error + } + }, + }, + + { + providerId: 'asana', + clientId: env.ASANA_CLIENT_ID as string, + clientSecret: env.ASANA_CLIENT_SECRET as string, + authorizationUrl: 'https://app.asana.com/-/oauth_authorize', + tokenUrl: 'https://app.asana.com/-/oauth_token', + userInfoUrl: 'https://app.asana.com/api/1.0/users/me', + scopes: getCanonicalScopesForProvider('asana'), + responseType: 'code', + pkce: false, + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/asana`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://app.asana.com/api/1.0/users/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Asana user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const result = await response.json() + const profile = result.data + + const now = new Date() + + return { + id: `${profile.gid.toString()}-${generateId()}`, + name: profile.name || 'Asana User', + email: profile.email || syntheticConnectorEmail('asana', profile.gid), + image: profile.photo?.image_128x128 || undefined, + emailVerified: !!profile.email, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Asana getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'slack', + clientId: env.SLACK_CLIENT_ID as string, + clientSecret: env.SLACK_CLIENT_SECRET as string, + authorizationUrl: 'https://slack.com/oauth/v2/authorize', + tokenUrl: 'https://slack.com/api/oauth.v2.access', + userInfoUrl: 'https://slack.com/api/users.identity', + scopes: getCanonicalScopesForProvider('slack'), + responseType: 'code', + accessType: 'offline', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/slack`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://slack.com/api/auth.test', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Slack auth.test failed', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + + if (!data.ok) { + logger.error('Slack auth.test returned error', { error: data.error }) + return null + } + + const teamId = data.team_id || 'unknown' + const teamName = data.team || 'Slack Workspace' + + /** + * Tag the accountId with the installing user's Slack id (from the OAuth + * v2 `authed_user.id`, preserved on `tokens.raw`) behind a `usr_` marker. + * The channels selector uses it to scope private-channel visibility to + * the installer's own Slack membership, per Slack Marketplace rules. The + * marker disambiguates it from a legacy bot id (same `U.../B...` shape); + * absent it, we keep the legacy format and today's behavior. + */ + const rawTokens = (tokens as typeof tokens & { raw?: Record }).raw + const authedUser = rawTokens?.authed_user as { id?: string } | undefined + const installerUserId = authedUser?.id + const userSegment = installerUserId + ? `usr_${installerUserId}` + : data.user_id || data.bot_id || 'bot' + + const uniqueId = `${teamId}-${userSegment}` + + logger.info('Slack credential identifier', { + teamId, + userSegment, + uniqueId, + teamName, + hasInstallerId: !!installerUserId, + }) + + return { + id: `${uniqueId}-${generateId()}`, + name: teamName, + email: syntheticConnectorEmail('slack', uniqueId), + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error creating Slack bot profile:', { error }) + return null + } + }, + }, + + { + providerId: 'webflow', + clientId: env.WEBFLOW_CLIENT_ID as string, + clientSecret: env.WEBFLOW_CLIENT_SECRET as string, + authorizationUrl: 'https://webflow.com/oauth/authorize', + tokenUrl: 'https://api.webflow.com/oauth/access_token', + userInfoUrl: 'https://api.webflow.com/v2/token/introspect', + scopes: getCanonicalScopesForProvider('webflow'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/webflow`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching Webflow user info') + + const response = await fetch('https://api.webflow.com/v2/token/introspect', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Webflow user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + const now = new Date() + + const userId = data.user_id || 'user' + const uniqueId = `webflow-${userId}` + + return { + id: `${uniqueId}-${generateId()}`, + name: data.user_name || 'Webflow User', + email: syntheticConnectorEmail('webflow', userId), + emailVerified: false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Webflow getUserInfo:', { error }) + return null + } + }, + }, + { + providerId: 'linkedin', + clientId: env.LINKEDIN_CLIENT_ID as string, + clientSecret: env.LINKEDIN_CLIENT_SECRET as string, + authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization', + tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken', + userInfoUrl: 'https://api.linkedin.com/v2/userinfo', + scopes: getCanonicalScopesForProvider('linkedin'), + responseType: 'code', + accessType: 'offline', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/linkedin`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching LinkedIn user profile') + + const response = await fetch('https://api.linkedin.com/v2/userinfo', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch LinkedIn user info', { + status: response.status, + statusText: response.statusText, + }) + throw new Error('Failed to fetch user info') + } + + const profile = await response.json() + + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'LinkedIn User', + email: profile.email || syntheticConnectorEmail('linkedin', profile.sub), + emailVerified: true, + image: profile.picture || undefined, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error in LinkedIn getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'zoom', + clientId: env.ZOOM_CLIENT_ID as string, + clientSecret: env.ZOOM_CLIENT_SECRET as string, + authorizationUrl: 'https://zoom.us/oauth/authorize', + tokenUrl: 'https://zoom.us/oauth/token', + userInfoUrl: 'https://api.zoom.us/v2/users/me', + scopes: getCanonicalScopesForProvider('zoom'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/zoom`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching Zoom user profile') + + const response = await fetch('https://api.zoom.us/v2/users/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Zoom user info', { + status: response.status, + statusText: response.statusText, + }) + throw new Error('Failed to fetch user info') + } + + const profile = await response.json() + + return { + id: `${profile.id.toString()}-${generateId()}`, + name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Zoom User', + email: profile.email || syntheticConnectorEmail('zoom', profile.id), + emailVerified: profile.verified === 1, + image: profile.pic_url || undefined, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error in Zoom getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'spotify', + clientId: env.SPOTIFY_CLIENT_ID as string, + clientSecret: env.SPOTIFY_CLIENT_SECRET as string, + authorizationUrl: 'https://accounts.spotify.com/authorize', + tokenUrl: 'https://accounts.spotify.com/api/token', + userInfoUrl: 'https://api.spotify.com/v1/me', + scopes: getCanonicalScopesForProvider('spotify'), + responseType: 'code', + authentication: 'basic', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/spotify`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching Spotify user profile') + + const response = await fetch('https://api.spotify.com/v1/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Spotify user info', { + status: response.status, + statusText: response.statusText, + }) + throw new Error('Failed to fetch user info') + } + + const profile = await response.json() + + return { + id: `${profile.id.toString()}-${generateId()}`, + name: profile.display_name || 'Spotify User', + email: profile.email || syntheticConnectorEmail('spotify', profile.id), + emailVerified: true, + image: profile.images?.[0]?.url || undefined, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error in Spotify getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'wordpress', + clientId: env.WORDPRESS_CLIENT_ID as string, + clientSecret: env.WORDPRESS_CLIENT_SECRET as string, + authorizationUrl: 'https://public-api.wordpress.com/oauth2/authorize', + tokenUrl: 'https://public-api.wordpress.com/oauth2/token', + userInfoUrl: 'https://public-api.wordpress.com/rest/v1.1/me', + scopes: getCanonicalScopesForProvider('wordpress'), + responseType: 'code', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wordpress`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching WordPress.com user profile') + + const response = await fetch('https://public-api.wordpress.com/rest/v1.1/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch WordPress.com user info', { + status: response.status, + statusText: response.statusText, + }) + throw new Error('Failed to fetch user info') + } + + const profile = await response.json() + + return { + id: `${profile.ID?.toString() || profile.id?.toString()}-${generateId()}`, + name: profile.display_name || profile.username || 'WordPress User', + email: + profile.email || + syntheticConnectorEmail('wordpress', profile.username ?? profile.ID ?? profile.id), + emailVerified: profile.email_verified || false, + image: profile.avatar_URL || undefined, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error in WordPress.com getUserInfo:', { error }) + return null + } + }, + }, + + // DocuSign provider + { + providerId: 'docusign', + clientId: env.DOCUSIGN_CLIENT_ID as string, + clientSecret: env.DOCUSIGN_CLIENT_SECRET as string, + authorizationUrl: 'https://account-d.docusign.com/oauth/auth', + tokenUrl: 'https://account-d.docusign.com/oauth/token', + userInfoUrl: 'https://account-d.docusign.com/oauth/userinfo', + scopes: getCanonicalScopesForProvider('docusign'), + responseType: 'code', + accessType: 'offline', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/docusign`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching DocuSign user profile') + + const response = await fetch('https://account-d.docusign.com/oauth/userinfo', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch DocuSign user info', { + status: response.status, + statusText: response.statusText, + }) + throw new Error('Failed to fetch user info') + } + + const data = await response.json() + const accounts = data.accounts ?? [] + const defaultAccount = + accounts.find((a: { is_default: boolean }) => a.is_default) ?? accounts[0] + const accountName = defaultAccount?.account_name || 'DocuSign Account' + + if (data.scope) { + tokens.scopes = data.scope.split(/\s+/).filter(Boolean) + } + + return { + id: `${data.sub}-${generateId()}`, + name: data.name || accountName, + email: data.email || syntheticConnectorEmail('docusign', data.sub), + emailVerified: true, + image: undefined, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error in DocuSign getUserInfo:', { error }) + return null + } + }, + }, + + // Cal.com provider + { + providerId: 'calcom', + clientId: env.CALCOM_CLIENT_ID as string, + authorizationUrl: 'https://app.cal.com/auth/oauth2/authorize', + tokenUrl: 'https://app.cal.com/api/auth/oauth/token', + scopes: getCanonicalScopesForProvider('calcom'), + responseType: 'code', + pkce: true, + accessType: 'offline', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/calcom`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching Cal.com user profile') + + const response = await fetch('https://api.cal.com/v2/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + 'cal-api-version': '2024-08-13', + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Cal.com user info', { + status: response.status, + statusText: response.statusText, + }) + throw new Error('Failed to fetch user info') + } + + const data = await response.json() + const profile = data.data || data + + return { + id: `${profile.id?.toString()}-${generateId()}`, + name: profile.name || 'Cal.com User', + email: profile.email || syntheticConnectorEmail('calcom', profile.id), + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error in Cal.com getUserInfo:', { error }) + return null + } + }, + }, + ] +} diff --git a/apps/sim/lib/copilot/async-runs/lifecycle.test.ts b/apps/sim/lib/copilot/async-runs/lifecycle.test.ts index 8cd4fd872e7..ecf31930c50 100644 --- a/apps/sim/lib/copilot/async-runs/lifecycle.test.ts +++ b/apps/sim/lib/copilot/async-runs/lifecycle.test.ts @@ -10,6 +10,7 @@ import { isAsyncTerminalConfirmationStatus, isDeliveredAsyncStatus, isTerminalAsyncStatus, + isWorkflowToolExecutionClaimable, } from './lifecycle' describe('async tool lifecycle helpers', () => { @@ -26,6 +27,17 @@ describe('async tool lifecycle helpers', () => { expect(isDeliveredAsyncStatus(ASYNC_TOOL_STATUS.delivered)).toBe(true) }) + it('claims only dispatched or explicitly approved workflow calls', () => { + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.running, null)).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.delivered, null)).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'allow')).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'allow_chat')).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'always_allow')).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'skip')).toBe(false) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, null)).toBe(false) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.completed, 'allow')).toBe(false) + }) + it('distinguishes background from terminal completion statuses', () => { expect(isAsyncEphemeralConfirmationStatus(ASYNC_TOOL_CONFIRMATION_STATUS.background)).toBe(true) expect(isAsyncEphemeralConfirmationStatus(ASYNC_TOOL_CONFIRMATION_STATUS.success)).toBe(false) diff --git a/apps/sim/lib/copilot/async-runs/lifecycle.ts b/apps/sim/lib/copilot/async-runs/lifecycle.ts index e54b2f1900a..d86ae06442a 100644 --- a/apps/sim/lib/copilot/async-runs/lifecycle.ts +++ b/apps/sim/lib/copilot/async-runs/lifecycle.ts @@ -1,4 +1,4 @@ -import type { CopilotAsyncToolStatus } from '@sim/db/schema' +import type { CopilotAsyncToolStatus, CopilotToolPermissionDecision } from '@sim/db/schema' import { MothershipStreamV1AsyncToolRecordStatus, MothershipStreamV1ToolOutcome, @@ -6,6 +6,12 @@ import { export const ASYNC_TOOL_STATUS = MothershipStreamV1AsyncToolRecordStatus +export const EXECUTABLE_TOOL_PERMISSION_DECISIONS = [ + 'allow', + 'allow_chat', + 'always_allow', +] as const satisfies readonly CopilotToolPermissionDecision[] + export type AsyncLifecycleStatus = | typeof ASYNC_TOOL_STATUS.pending | typeof ASYNC_TOOL_STATUS.running @@ -81,6 +87,23 @@ export interface AsyncCompletionSignal { data?: AsyncCompletionData } +export function isExecutableToolPermissionDecision( + decision: CopilotToolPermissionDecision | null | undefined +): boolean { + return decision !== null && decision !== undefined && decision !== 'skip' +} + +export function isWorkflowToolExecutionClaimable( + status: CopilotAsyncToolStatus, + permissionDecision: CopilotToolPermissionDecision | null | undefined +): boolean { + return ( + status === ASYNC_TOOL_STATUS.running || + status === ASYNC_TOOL_STATUS.delivered || + (status === ASYNC_TOOL_STATUS.pending && isExecutableToolPermissionDecision(permissionDecision)) + ) +} + export function isTerminalAsyncStatus( status: CopilotAsyncToolStatus | AsyncLifecycleStatus | string | null | undefined ): status is AsyncTerminalStatus { diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index 50e36eaecd1..fcd9c01a4e7 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -7,8 +7,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { claimCompletedAsyncToolCall, claimPendingAsyncToolCall, + claimWorkflowToolExecution, completeAsyncToolCall, - markAsyncToolDelivered, + detachAsyncToolCall, + getClaimedWorkflowExecutionId, + recordToolPermissionDecision, + releaseWorkflowToolExecutionClaim, + replaceTerminalAsyncToolCallResult, + upsertAsyncToolCall, } from './repository' describe('async tool repository single-row semantics', () => { @@ -17,27 +23,48 @@ describe('async tool repository single-row semantics', () => { resetDbChainMock() }) - it('does not overwrite a delivered row on late completion', async () => { - const deliveredRow = { + it('atomically completes a live row', async () => { + const completedRow = { toolCallId: 'tool-1', - status: 'delivered', + status: 'completed', result: { ok: true }, error: null, } - dbChainMockFns.limit.mockResolvedValueOnce([deliveredRow]) + dbChainMockFns.returning.mockResolvedValueOnce([completedRow]) const result = await completeAsyncToolCall({ toolCallId: 'tool-1', status: 'completed', - result: { ok: false }, + result: { ok: true }, error: null, }) - expect(result).toEqual(deliveredRow) - expect(dbChainMockFns.returning).not.toHaveBeenCalled() + expect(result).toEqual(completedRow) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'completed', + result: { ok: true }, + completedAt: expect.any(Date), + }) + ) + expect(dbChainMockFns.where).toHaveBeenCalled() }) - it('marks a row delivered and clears the claim fields', async () => { + it('returns null when another terminal transition already won', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const result = await completeAsyncToolCall({ + toolCallId: 'tool-1', + status: 'failed', + result: null, + error: 'late error', + }) + + expect(result).toBeNull() + expect(dbChainMockFns.limit).not.toHaveBeenCalled() + }) + + it('atomically detaches a live background call and clears the claim fields', async () => { dbChainMockFns.returning.mockResolvedValueOnce([ { toolCallId: 'tool-1', @@ -45,7 +72,7 @@ describe('async tool repository single-row semantics', () => { }, ]) - await markAsyncToolDelivered('tool-1') + await detachAsyncToolCall('tool-1') expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ @@ -54,6 +81,7 @@ describe('async tool repository single-row semantics', () => { claimedAt: null, }) ) + expect(dbChainMockFns.where).toHaveBeenCalled() }) it('claims only completed rows for delivery handoff', async () => { @@ -103,4 +131,149 @@ describe('async tool repository single-row semantics', () => { }) ) }) + + it('atomically binds an eligible workflow tool to one execution', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'running', + claimedBy: 'workflow:execution-1', + }, + ]) + + const result = await claimWorkflowToolExecution('workflow-tool', 'execution-1') + + expect(result).toMatchObject({ + toolCallId: 'workflow-tool', + claimedBy: 'workflow:execution-1', + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + status: expect.anything(), + claimedBy: 'workflow:execution-1', + claimedAt: expect.any(Date), + updatedAt: expect.any(Date), + }) + expect(getClaimedWorkflowExecutionId(result?.claimedBy)).toBe('execution-1') + }) + + it('returns null when a workflow tool execution claim loses the race', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect(claimWorkflowToolExecution('workflow-tool', 'execution-2')).resolves.toBeNull() + }) + + it('releases a matching pre-start workflow claim without changing its lifecycle status', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'delivered', + claimedBy: null, + }, + ]) + + const result = await releaseWorkflowToolExecutionClaim('workflow-tool', 'execution-1') + + expect(result).toMatchObject({ + toolCallId: 'workflow-tool', + status: 'delivered', + claimedBy: null, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + claimedBy: null, + claimedAt: null, + updatedAt: expect.any(Date), + }) + }) + + it('detaches a bound workflow waiter without releasing its execution claim', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'delivered', + claimedBy: 'workflow:execution-1', + }, + ]) + + await detachAsyncToolCall('workflow-tool', { preserveClaim: true }) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'delivered', + claimedBy: undefined, + claimedAt: undefined, + }) + ) + }) + + it('records an approved workflow decision without changing execution state', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'pending', + permissionDecision: 'allow', + }, + ]) + + await recordToolPermissionDecision('workflow-tool', 'allow') + + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + permissionDecision: 'allow', + permissionDecidedAt: expect.any(Date), + updatedAt: expect.any(Date), + }) + }) + + it('replaces only terminal payload fields after trusted projection', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'completed', + result: { output: '{{SECRET}}' }, + }, + ]) + + const result = await replaceTerminalAsyncToolCallResult({ + toolCallId: 'workflow-tool', + status: 'completed', + result: { output: '{{SECRET}}' }, + error: null, + }) + + expect(result).toMatchObject({ + toolCallId: 'workflow-tool', + status: 'completed', + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + status: 'completed', + result: { output: '{{SECRET}}' }, + error: null, + updatedAt: expect.any(Date), + }) + expect(dbChainMockFns.where).toHaveBeenCalled() + }) + + it.each(['pending', 'running'] as const)( + 'keeps the first finalized call identity immutable after it reaches %s', + async (status) => { + const existingRow = { + runId: 'run-1', + toolCallId: 'tool-1', + toolName: 'function_execute', + args: { language: 'javascript', code: 'return {{FIRST_SECRET}}' }, + status, + } + dbChainMockFns.limit.mockResolvedValueOnce([existingRow]) + + const result = await upsertAsyncToolCall({ + runId: 'run-1', + toolCallId: 'tool-1', + toolName: 'function_execute', + args: { language: 'javascript', code: 'return {{SECOND_SECRET}}' }, + status: 'pending', + }) + + expect(result).toEqual(existingRow) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + } + ) }) diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index 257bfdaec2d..3497be4e987 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -11,18 +11,19 @@ import { import { createLogger } from '@sim/logger' import { filterUndefined } from '@sim/utils/object' import { sanitizeValueForJsonb } from '@sim/utils/string' -import { and, desc, eq, inArray, isNull } from 'drizzle-orm' +import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { markSpanForError } from '@/lib/copilot/request/otel' import { ASYNC_TOOL_STATUS, type AsyncCompletionData, - isDeliveredAsyncStatus, - isTerminalAsyncStatus, + type AsyncTerminalStatus, + EXECUTABLE_TOOL_PERMISSION_DECISIONS, } from './lifecycle' const logger = createLogger('CopilotAsyncRunsRepo') +const WORKFLOW_EXECUTION_CLAIM_PREFIX = 'workflow:' // Resolve the tracer lazily per-call to avoid capturing the NoOp tracer // before NodeSDK installs the global TracerProvider (Next.js 16/Turbopack // can evaluate modules before instrumentation-node.ts finishes). @@ -193,6 +194,7 @@ export async function getRunSegment(runId: string) { id: copilotRuns.id, userId: copilotRuns.userId, status: copilotRuns.status, + workflowId: copilotRuns.workflowId, // Needed to scope an "allow for this chat" decision to its chat. chatId: copilotRuns.chatId, }) @@ -243,6 +245,7 @@ export async function upsertAsyncToolCall(input: { toolName: string args?: Record status?: CopilotAsyncToolStatus + sealedContext?: AsyncCompletionData }) { return withDbSpan( TraceSpan.CopilotAsyncRunsUpsertAsyncToolCall, @@ -256,21 +259,10 @@ export async function upsertAsyncToolCall(input: { }, async () => { const existing = await getAsyncToolCall(input.toolCallId) + if (existing) return existing + const incomingStatus = input.status ?? 'pending' - if ( - existing && - (isTerminalAsyncStatus(existing.status) || isDeliveredAsyncStatus(existing.status)) && - !isTerminalAsyncStatus(incomingStatus) && - !isDeliveredAsyncStatus(incomingStatus) - ) { - logger.info('Ignoring async tool upsert that would downgrade terminal state', { - toolCallId: input.toolCallId, - existingStatus: existing.status, - incomingStatus, - }) - return existing - } - const effectiveRunId = input.runId ?? existing?.runId ?? null + const effectiveRunId = input.runId ?? null if (!effectiveRunId) { logger.warn('upsertAsyncToolCall missing runId and no existing row', { toolCallId: input.toolCallId, @@ -282,6 +274,7 @@ export async function upsertAsyncToolCall(input: { const now = new Date() const args = sanitizeValueForJsonb(input.args ?? {}) + const sealedContext = sanitizeValueForJsonb(input.sealedContext) const [row] = await db .insert(copilotAsyncToolCalls) .values({ @@ -291,22 +284,13 @@ export async function upsertAsyncToolCall(input: { toolName: input.toolName, args, status: incomingStatus, + ...(sealedContext !== undefined ? { result: sealedContext } : {}), updatedAt: now, }) - .onConflictDoUpdate({ - target: copilotAsyncToolCalls.toolCallId, - set: { - runId: effectiveRunId, - checkpointId: input.checkpointId ?? null, - toolName: input.toolName, - args, - status: incomingStatus, - updatedAt: now, - }, - }) + .onConflictDoNothing() .returning() - return row + return row ?? getAsyncToolCall(input.toolCallId) } ) } @@ -337,7 +321,8 @@ async function markAsyncToolStatus( result?: AsyncCompletionData | null error?: string | null completedAt?: Date | null - } = {} + } = {}, + expectedStatuses?: CopilotAsyncToolStatus[] ) { return withDbSpan( TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, @@ -370,7 +355,14 @@ async function markAsyncToolStatus( completedAt: updates.completedAt, updatedAt: new Date(), }) - .where(eq(copilotAsyncToolCalls.toolCallId, toolCallId)) + .where( + expectedStatuses + ? and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + inArray(copilotAsyncToolCalls.status, expectedStatuses) + ) + : eq(copilotAsyncToolCalls.toolCallId, toolCallId) + ) .returning() return row ?? null @@ -382,6 +374,90 @@ export async function markAsyncToolRunning(toolCallId: string, claimedBy: string return markAsyncToolStatus(toolCallId, 'running', { claimedBy }) } +export function getClaimedWorkflowExecutionId(claimedBy: string | null | undefined) { + if (!claimedBy?.startsWith(WORKFLOW_EXECUTION_CLAIM_PREFIX)) return undefined + const executionId = claimedBy.slice(WORKFLOW_EXECUTION_CLAIM_PREFIX.length) + return executionId.length > 0 ? executionId : undefined +} + +export async function claimWorkflowToolExecution(toolCallId: string, executionId: string) { + const claimedBy = `${WORKFLOW_EXECUTION_CLAIM_PREFIX}${executionId}` + return withDbSpan( + TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.CopilotAsyncToolClaimedBy]: claimedBy, + }, + async () => { + const now = new Date() + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + status: sql`CASE WHEN ${copilotAsyncToolCalls.status} = ${ASYNC_TOOL_STATUS.pending} THEN ${ASYNC_TOOL_STATUS.running} ELSE ${copilotAsyncToolCalls.status} END`, + claimedBy, + claimedAt: now, + updatedAt: now, + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + isNull(copilotAsyncToolCalls.claimedBy), + or( + inArray(copilotAsyncToolCalls.status, [ + ASYNC_TOOL_STATUS.running, + ASYNC_TOOL_STATUS.delivered, + ]), + and( + eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending), + inArray(copilotAsyncToolCalls.permissionDecision, [ + ...EXECUTABLE_TOOL_PERMISSION_DECISIONS, + ]) + ) + ) + ) + ) + .returning() + return row ?? null + } + ) +} + +export async function releaseWorkflowToolExecutionClaim(toolCallId: string, executionId: string) { + const claimedBy = `${WORKFLOW_EXECUTION_CLAIM_PREFIX}${executionId}` + return withDbSpan( + TraceSpan.CopilotAsyncRunsReleaseClaim, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.CopilotAsyncToolClaimedBy]: claimedBy, + }, + async () => { + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + claimedBy: null, + claimedAt: null, + updatedAt: new Date(), + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + eq(copilotAsyncToolCalls.claimedBy, claimedBy), + inArray(copilotAsyncToolCalls.status, [ + ASYNC_TOOL_STATUS.running, + ASYNC_TOOL_STATUS.delivered, + ]) + ) + ) + .returning() + return row ?? null + } + ) +} + /** * Atomically claims a pending client tool exactly once. Native browser actions * use this before crossing the Electron boundary so a replayed renderer event @@ -425,27 +501,79 @@ export async function completeAsyncToolCall(input: { result?: AsyncCompletionData | null error?: string | null }) { - const existing = await getAsyncToolCall(input.toolCallId) - - if (!existing) { - logger.warn('completeAsyncToolCall called before pending row existed', { - toolCallId: input.toolCallId, - status: input.status, - }) - return null - } + return markAsyncToolStatus( + input.toolCallId, + input.status, + { + claimedBy: null, + claimedAt: null, + result: input.result ?? null, + error: input.error ?? null, + completedAt: new Date(), + }, + [ASYNC_TOOL_STATUS.pending, ASYNC_TOOL_STATUS.running] + ) +} - if (isTerminalAsyncStatus(existing.status) || isDeliveredAsyncStatus(existing.status)) { - return existing - } +/** + * Atomically detaches a live client tool after the browser reports that it is + * continuing in the background. Whichever terminal or detach transition wins + * is the only result eligible for publication. + */ +export async function detachAsyncToolCall( + toolCallId: string, + options?: { preserveClaim?: boolean } +) { + return markAsyncToolStatus( + toolCallId, + ASYNC_TOOL_STATUS.delivered, + options?.preserveClaim ? {} : { claimedBy: null, claimedAt: null }, + [ASYNC_TOOL_STATUS.pending, ASYNC_TOOL_STATUS.running] + ) +} - return markAsyncToolStatus(input.toolCallId, input.status, { - claimedBy: null, - claimedAt: null, - result: input.result ?? null, - error: input.error ?? null, - completedAt: new Date(), - }) +/** + * Replaces an already-terminal async tool call from a trusted producer. + * + * Client workflow confirmations are persisted structurally first. The live + * Copilot waiter uses this guarded update only after it has restored and + * projected the server-owned workflow result. + */ +export async function replaceTerminalAsyncToolCallResult(input: { + toolCallId: string + status: AsyncTerminalStatus + result: AsyncCompletionData | null + error: string | null +}) { + return withDbSpan( + TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: input.toolCallId, + [TraceAttr.CopilotAsyncToolStatus]: input.status, + [TraceAttr.CopilotAsyncToolHasError]: !!input.error, + }, + async () => { + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + status: input.status, + result: sanitizeValueForJsonb(input.result), + error: input.error, + updatedAt: new Date(), + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, input.toolCallId), + eq(copilotAsyncToolCalls.status, input.status) + ) + ) + .returning() + + return row ?? null + } + ) } /** @@ -480,7 +608,8 @@ export async function recordToolPermissionDecision( .where( and( eq(copilotAsyncToolCalls.toolCallId, toolCallId), - isNull(copilotAsyncToolCalls.permissionDecision) + isNull(copilotAsyncToolCalls.permissionDecision), + eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending) ) ) .returning() @@ -489,13 +618,6 @@ export async function recordToolPermissionDecision( ) } -export async function markAsyncToolDelivered(toolCallId: string) { - return markAsyncToolStatus(toolCallId, ASYNC_TOOL_STATUS.delivered, { - claimedBy: null, - claimedAt: null, - }) -} - async function listAsyncToolCallsForRun(runId: string) { return withDbSpan( TraceSpan.CopilotAsyncRunsListForRun, diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 1de5ea17dcb..cfd555b6db8 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -14,11 +14,12 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const resolveWorkflowIdForUser = workflowsUtilsMockFns.mockResolveWorkflowIdForUser const getUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions -const getEffectiveDecryptedEnv = environmentUtilsMockFns.mockGetEffectiveDecryptedEnv +const getEffectiveEnvironmentSnapshot = environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot const { generateWorkspaceSnapshot, @@ -134,7 +135,14 @@ describe('handleUnifiedChatPost', () => { }) getUserEntityPermissions.mockResolvedValue('write') resolveBillingAttribution.mockResolvedValue(billingAttribution) - getEffectiveDecryptedEnv.mockResolvedValue({ API_KEY: 'secret' }) + getEffectiveEnvironmentSnapshot.mockResolvedValue({ + personalEncrypted: { API_KEY: 'encrypted-secret' }, + workspaceEncrypted: {}, + personalDecrypted: { API_KEY: 'secret' }, + workspaceDecrypted: {}, + conflicts: [], + decryptionFailures: [], + }) generateWorkspaceSnapshot.mockResolvedValue({ markdown: 'workspace context', snapshot: { workflows: [{ id: 'wf-1', name: 'Alpha', path: 'workflows/Alpha' }] }, @@ -197,6 +205,7 @@ describe('handleUnifiedChatPost', () => { workspaceId: 'ws-1', billingAttribution, requestMode: 'agent', + resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), }), }), }) @@ -238,6 +247,7 @@ describe('handleUnifiedChatPost', () => { workspaceId: 'ws-1', billingAttribution, requestMode: 'agent', + resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), }), }), }) diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 4b802c1f27f..27855a7d911 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -32,6 +32,7 @@ import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context' import { chatPubSub } from '@/lib/copilot/chat-status' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' +import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { CopilotChatFinalizeOutcome, CopilotChatPersistOutcome, @@ -52,7 +53,6 @@ import type { ExecutionContext, OrchestratorResult } from '@/lib/copilot/request import { persistChatResources } from '@/lib/copilot/resources/persistence' import { isEphemeralResource } from '@/lib/copilot/resources/types' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { captureServerEvent } from '@/lib/posthog/server' import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { @@ -561,8 +561,8 @@ async function buildInitialExecutionContext(params: { } } - const [decryptedEnvVars, billingAttribution] = await Promise.all([ - getEffectiveDecryptedEnv(userId, workspaceId), + const [environmentContext, billingAttribution] = await Promise.all([ + prepareCopilotEnvironmentContext(userId, workspaceId), workspaceId ? resolveBillingAttribution({ actorUserId: userId, workspaceId }) : Promise.resolve(undefined), @@ -572,7 +572,7 @@ async function buildInitialExecutionContext(params: { workflowId: workflowId ?? '', workspaceId, chatId, - decryptedEnvVars, + ...environmentContext, billingAttribution, messageId, userTimezone, diff --git a/apps/sim/lib/copilot/environment-context.test.ts b/apps/sim/lib/copilot/environment-context.test.ts new file mode 100644 index 00000000000..e4cee310987 --- /dev/null +++ b/apps/sim/lib/copilot/environment-context.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ +import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' +import { afterEach, describe, expect, it } from 'vitest' +import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' + +describe('prepareCopilotEnvironmentContext', () => { + afterEach(() => { + resetEnvironmentUtilsMock() + }) + + it('keeps decrypted values only in the inert provenance registry', async () => { + environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({ + personalEncrypted: { + SHARED_SECRET: 'personal-encrypted', + PERSONAL_ONLY: 'personal-only-encrypted', + }, + workspaceEncrypted: { + SHARED_SECRET: 'workspace-encrypted', + WORKSPACE_ONLY: 'workspace-only-encrypted', + }, + personalDecrypted: { + SHARED_SECRET: 'personal-value', + PERSONAL_ONLY: 'personal-only-value', + }, + workspaceDecrypted: { + SHARED_SECRET: 'workspace-value', + WORKSPACE_ONLY: 'workspace-only-value', + }, + conflicts: ['SHARED_SECRET'], + decryptionFailures: [], + }) + + const context = await prepareCopilotEnvironmentContext('user-1', 'workspace-1') + + expect(context).not.toHaveProperty('decryptedEnvVars') + expect(context.resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect( + context.resolvedSecretTraceRegistry.recordResolved('SHARED_SECRET', 'workspace-value') + ).toBe(true) + expect(context.resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ + { plaintext: 'workspace-value', replacement: '{{SHARED_SECRET}}' }, + ]) + expect( + environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot + ).toHaveBeenCalledExactlyOnceWith('user-1', 'workspace-1') + }) +}) diff --git a/apps/sim/lib/copilot/environment-context.ts b/apps/sim/lib/copilot/environment-context.ts new file mode 100644 index 00000000000..d939d60a69a --- /dev/null +++ b/apps/sim/lib/copilot/environment-context.ts @@ -0,0 +1,35 @@ +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { + type EnvironmentResolutionSnapshot, + getEffectiveEnvironmentSnapshot, +} from '@/lib/environment/utils' +import { createResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +export type CopilotEnvironmentContext = Pick + +export async function createCopilotEnvironmentContext( + userId: string, + workspaceId: string | undefined, + environment: EnvironmentResolutionSnapshot +): Promise { + const resolvedSecretTraceRegistry = await createResolvedSecretTraceRegistry({ + personalEncrypted: environment.personalEncrypted, + workspaceEncrypted: environment.workspaceEncrypted, + personalDecrypted: environment.personalDecrypted, + workspaceDecrypted: environment.workspaceDecrypted, + decryptionFailures: environment.decryptionFailures, + scope: { userId, workspaceId }, + }) + + return { + resolvedSecretTraceRegistry, + } +} + +export async function prepareCopilotEnvironmentContext( + userId: string, + workspaceId?: string +): Promise { + const environment = await getEffectiveEnvironmentSnapshot(userId, workspaceId) + return createCopilotEnvironmentContext(userId, workspaceId, environment) +} diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index cda8d3097ea..292b1492232 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -1768,7 +1768,7 @@ export const FunctionExecute: ToolCatalogEntry = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -3489,21 +3489,27 @@ export const QueryUserTable: ToolCatalogEntry = { type: 'object', description: 'Arguments for the operation', properties: { - filter: { type: 'object', description: 'MongoDB-style filter for query_rows' }, - limit: { - type: 'number', - description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, + filter: { + type: 'object', + description: + 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', }, - offset: { + limit: { type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', + description: + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).', }, - rowId: { type: 'string', description: 'Row ID (required for get_row)' }, - sort: { - type: 'object', + order: { + type: 'array', description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', }, + rowId: { type: 'string', description: 'Row ID (required for get_row)' }, tableId: { type: 'string', description: 'Table ID (required for all operations)' }, }, }, @@ -3766,7 +3772,7 @@ export const RunCode: ToolCatalogEntry = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -4742,17 +4748,17 @@ export const UserTable: ToolCatalogEntry = { description: 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, - order: { - type: 'array', - description: - 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', - }, options: { type: 'array', description: 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', items: { type: 'string' }, }, + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + }, outputColumnNames: { type: 'object', description: diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 825443e2447..421ddcadb25 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1464,7 +1464,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -3148,27 +3148,30 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', description: 'Arguments for the operation', properties: { + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, filter: { type: 'object', - description: 'MongoDB-style filter for query_rows', + description: + 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', }, limit: { type: 'number', - description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + description: + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).', }, - offset: { - type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', }, rowId: { type: 'string', description: 'Row ID (required for get_row)', }, - sort: { - type: 'object', - description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", - }, tableId: { type: 'string', description: 'Table ID (required for all operations)', @@ -3435,7 +3438,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -4247,6 +4250,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.', }, + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, data: { type: 'object', description: 'Row data as key-value pairs (required for insert_row, update_row)', @@ -4289,7 +4297,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { filter: { type: 'object', description: - 'MongoDB-style filter for query_rows, update_rows_by_filter, delete_rows_by_filter', + 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.', }, groupId: { type: 'string', @@ -4326,7 +4334,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { limit: { type: 'number', description: - 'Maximum rows to return or affect (optional, default 100). Omit on update_rows_by_filter / delete_rows_by_filter to act on every match.', + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor). On update_rows_by_filter / delete_rows_by_filter, caps affected rows; omit to act on every match.', }, mapping: { type: 'object', @@ -4388,10 +4396,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, - offset: { - type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', - }, options: { type: 'array', description: @@ -4400,6 +4404,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', }, }, + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + }, outputColumnNames: { type: 'object', description: @@ -4492,11 +4501,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { "Cancellation scope for cancel_table_runs. 'all' cancels in-flight runs across the whole table; 'row' cancels only the row identified by rowId.", enum: ['all', 'row'], }, - sort: { - type: 'object', - description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", - }, tableId: { type: 'string', description: diff --git a/apps/sim/lib/copilot/persistence/tool-confirm/index.ts b/apps/sim/lib/copilot/persistence/tool-confirm/index.ts index 09f5fd2ee93..dd1a41006b2 100644 --- a/apps/sim/lib/copilot/persistence/tool-confirm/index.ts +++ b/apps/sim/lib/copilot/persistence/tool-confirm/index.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { + ASYNC_TOOL_CONFIRMATION_STATUS, ASYNC_TOOL_STATUS, type AsyncCompletionEnvelope, type AsyncConfirmationState, @@ -46,10 +47,10 @@ export async function getToolConfirmation( }) if (!row) return null if (row.status === ASYNC_TOOL_STATUS.delivered) { - logger.warn('Delivered async tool rows are outside request confirmation flow', { - toolCallId, - }) - return null + return { + status: ASYNC_TOOL_CONFIRMATION_STATUS.background, + timestamp: row.updatedAt?.toISOString?.(), + } } return { status: diff --git a/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts b/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts index efe38c759ab..7b72bce8255 100644 --- a/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts +++ b/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts @@ -82,7 +82,7 @@ describe('copilot orchestrator persistence', () => { }) }) - it('ignores delivered rows in request confirmation flow', async () => { + it('reconstructs background from a delivered durable row', async () => { row = { status: 'delivered', result: { ok: true }, @@ -90,7 +90,10 @@ describe('copilot orchestrator persistence', () => { updatedAt: new Date('2026-01-01T00:00:00.000Z'), } - await expect(getToolConfirmation('tool-1')).resolves.toBeNull() + await expect(getToolConfirmation('tool-1')).resolves.toEqual({ + status: 'background', + timestamp: '2026-01-01T00:00:00.000Z', + }) }) it('ignores background when waiting for a foreground terminal status', async () => { @@ -163,4 +166,22 @@ describe('copilot orchestrator persistence', () => { timestamp: '2026-01-01T00:00:01.000Z', }) }) + + it('resolves background when detach completes before the waiter subscribes', async () => { + row = { + status: 'delivered', + error: null, + result: null, + updatedAt: new Date('2026-01-01T00:00:01.000Z'), + } + + await expect( + waitForToolConfirmation('tool-1', 5_000, undefined, { + acceptStatus: (status) => status === 'background', + }) + ).resolves.toEqual({ + status: 'background', + timestamp: '2026-01-01T00:00:01.000Z', + }) + }) }) diff --git a/apps/sim/lib/copilot/persistence/tool-permission/index.ts b/apps/sim/lib/copilot/persistence/tool-permission/index.ts index 718b6e9e3e6..083d11004d2 100644 --- a/apps/sim/lib/copilot/persistence/tool-permission/index.ts +++ b/apps/sim/lib/copilot/persistence/tool-permission/index.ts @@ -1,6 +1,7 @@ import type { CopilotToolPermissionDecision } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { isExecutableToolPermissionDecision } from '@/lib/copilot/async-runs/lifecycle' import { getAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub' @@ -26,7 +27,7 @@ export interface ToolPermissionEnvelope { /** Every allow variant runs the tool; they differ only in what gets remembered. */ export function decisionAllowsExecution(decision: ToolPermissionDecision): boolean { - return decision !== TOOL_PERMISSION_DECISION.skip + return isExecutableToolPermissionDecision(decision) } /** True for the decisions that suppress future prompts for the same tool. */ diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts index ebc2ce9f1be..1947b635512 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -32,7 +32,10 @@ function makeContext(): StreamingContext { wasAborted: false, errors: [], trace: new TraceCollector(), - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { + enabled: false, + autoAllowed: new Set(), + }, } } diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 33979504936..efa9d8ef7d8 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -109,7 +109,10 @@ function createStreamingContext(): StreamingContext { errors: [], activeFileIntents: new Map(), trace: new TraceCollector(), - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { + enabled: false, + autoAllowed: new Set(), + }, } } diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index 5ebe3be2c4b..471904c16fe 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -377,7 +377,7 @@ export async function runStreamLoop( state: filePreviewAdapterState, }) - await prePersistClientExecutableToolCall(streamEvent, context, options) + await prePersistClientExecutableToolCall(streamEvent, context, options, execContext) try { await options.onEvent?.(streamEvent) diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index fb595791464..c3f92fe3c06 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -15,16 +15,21 @@ const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApprov }) ) -const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall, markAsyncToolDelivered } = +const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = vi.hoisted(() => ({ + upsertAsyncToolCall: vi.fn(), + markAsyncToolRunning: vi.fn(), + completeAsyncToolCall: vi.fn(), +})) + +const { waitForClientToolCompletion, waitForToolCompletion, waitForWorkflowToolCompletion } = vi.hoisted(() => ({ - upsertAsyncToolCall: vi.fn(), - markAsyncToolRunning: vi.fn(), - completeAsyncToolCall: vi.fn(), - markAsyncToolDelivered: vi.fn(), + waitForClientToolCompletion: vi.fn(), + waitForToolCompletion: vi.fn(), + waitForWorkflowToolCompletion: vi.fn(), })) -const { waitForToolCompletion } = vi.hoisted(() => ({ - waitForToolCompletion: vi.fn(), +const { sealClientToolContext } = vi.hoisted(() => ({ + sealClientToolContext: vi.fn(), })) vi.mock('@/lib/copilot/tool-executor', () => ({ @@ -50,12 +55,17 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ releaseCompletedAsyncToolClaim: vi.fn(), upsertAsyncToolCall, markAsyncToolRunning, - markAsyncToolDelivered, completeAsyncToolCall, })) vi.mock('@/lib/copilot/request/tools/client', () => ({ + waitForClientToolCompletion, waitForToolCompletion, + waitForWorkflowToolCompletion, +})) + +vi.mock('@/lib/copilot/request/tools/client-completion-seal.server', () => ({ + sealClientToolContext, })) import { @@ -70,13 +80,14 @@ import { MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' -import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { prePersistClientExecutableToolCall, sseHandlers, subAgentHandlers, } from '@/lib/copilot/request/handlers' import type { ExecutionContext, StreamEvent, StreamingContext } from '@/lib/copilot/request/types' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' describe('sse-handlers tool lifecycle', () => { let context: StreamingContext @@ -88,8 +99,12 @@ describe('sse-handlers tool lifecycle', () => { upsertAsyncToolCall.mockResolvedValue(null) markAsyncToolRunning.mockResolvedValue(null) completeAsyncToolCall.mockResolvedValue(null) - markAsyncToolDelivered.mockResolvedValue(null) waitForToolCompletion.mockResolvedValue(null) + waitForClientToolCompletion.mockResolvedValue(null) + waitForWorkflowToolCompletion.mockResolvedValue(null) + sealClientToolContext.mockResolvedValue({ + __sealedClientToolContextV1: 'sealed-context', + }) context = { chatId: undefined, messageId: 'msg-1', @@ -109,11 +124,15 @@ describe('sse-handlers tool lifecycle', () => { streamComplete: false, wasAborted: false, errors: [], - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { + enabled: false, + autoAllowed: new Set(), + }, } execContext = { userId: 'user-1', workflowId: 'workflow-1', + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([]), } }) @@ -133,7 +152,9 @@ describe('sse-handlers tool lifecycle', () => { phase: MothershipStreamV1ToolPhase.call, }, } satisfies StreamEvent, - context + context, + {}, + execContext ) expect(upsertAsyncToolCall).toHaveBeenCalledWith({ @@ -141,14 +162,24 @@ describe('sse-handlers tool lifecycle', () => { toolCallId: 'browser-tool-1', toolName: 'browser_list_tabs', args: {}, + sealedContext: { __sealedClientToolContextV1: 'sealed-context' }, status: MothershipStreamV1AsyncToolRecordStatus.pending, }) + expect(sealClientToolContext).toHaveBeenCalledWith({ + toolCallId: 'browser-tool-1', + runId: 'run-1', + userId: 'user-1', + registry: execContext.resolvedSecretTraceRegistry, + }) }) it('persists a gated sim tool and stamps the frame so a reload can still answer it', async () => { toolRequiresApproval.mockReturnValue(true) context.runId = 'run-1' - context.toolPermissions = { enabled: true, autoAllowed: new Set() } + context.toolPermissions = { + enabled: true, + autoAllowed: new Set(), + } const event = { type: MothershipStreamV1EventType.tool, @@ -182,7 +213,10 @@ describe('sse-handlers tool lifecycle', () => { // answer into a disabled endpoint. toolRequiresApproval.mockReturnValue(false) context.runId = 'run-1' - context.toolPermissions = { enabled: false, autoAllowed: new Set() } + context.toolPermissions = { + enabled: false, + autoAllowed: new Set(), + } const event = { type: MothershipStreamV1EventType.tool, @@ -206,7 +240,10 @@ describe('sse-handlers tool lifecycle', () => { it('clears a Go-stamped approval frame on an internal tool', async () => { toolRequiresApproval.mockReturnValue(true) context.runId = 'run-1' - context.toolPermissions = { enabled: true, autoAllowed: new Set() } + context.toolPermissions = { + enabled: true, + autoAllowed: new Set(), + } const event = { type: MothershipStreamV1EventType.tool, @@ -232,7 +269,10 @@ describe('sse-handlers tool lifecycle', () => { it('leaves an already always-allowed tool ungated', async () => { toolRequiresApproval.mockReturnValue(true) context.runId = 'run-1' - context.toolPermissions = { enabled: true, autoAllowed: new Set(['deploy_api']) } + context.toolPermissions = { + enabled: true, + autoAllowed: new Set(['deploy_api']), + } const event = { type: MothershipStreamV1EventType.tool, @@ -431,8 +471,82 @@ describe('sse-handlers tool lifecycle', () => { expect(updated?.result?.output).toBe('done') }) - it('marks background client workflow tools delivered after synthetic result emission', async () => { - waitForToolCompletion.mockResolvedValueOnce({ + it('projects resolved Function secrets before every Copilot-visible result sink', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'SECRET', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ]) + registry.recordResolved('SECRET', 'secret-value') + execContext.resolvedSecretTraceRegistry = registry + execContext.chatId = 'chat-1' + executeTool.mockResolvedValueOnce({ + success: true, + output: { + result: 'secret-value', + stdout: 'prefix secret-value', + }, + resources: [{ type: 'file', id: 'file-1', title: 'secret-value.txt' }], + }) + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-function', + toolName: FunctionExecute.id, + arguments: { code: 'return {{SECRET}}' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: false, timeout: 1000 } + ) + + await sleep(0) + + const safeOutput = { + result: '{{SECRET}}', + stdout: 'prefix {{SECRET}}', + } + expect(completeAsyncToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolCallId: 'tool-function', + result: safeOutput, + }) + ) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + toolCallId: 'tool-function', + output: safeOutput, + }), + }) + ) + expect(context.toolCalls.get('tool-function')?.result?.output).toEqual(safeOutput) + expect(onEvent).toHaveBeenCalledWith({ + type: MothershipStreamV1EventType.resource, + payload: { + op: MothershipStreamV1ResourceOp.upsert, + resource: { + type: 'file', + id: 'file-1', + title: '{{SECRET}}.txt', + }, + }, + }) + expect(JSON.stringify(completeAsyncToolCall.mock.calls)).not.toContain('secret-value') + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('secret-value') + }) + + it('emits a structural result for a detached background workflow tool', async () => { + waitForWorkflowToolCompletion.mockResolvedValueOnce({ status: 'background', data: { detached: true }, }) @@ -458,7 +572,13 @@ describe('sse-handlers tool lifecycle', () => { await sleep(0) await Promise.allSettled(context.pendingToolPromises.values()) - expect(markAsyncToolDelivered).toHaveBeenCalledWith('tool-background') + expect(waitForWorkflowToolCompletion).toHaveBeenCalledWith({ + toolCallId: 'tool-background', + workflowId: 'workflow-1', + timeoutMs: 1000, + abortSignal: undefined, + registry: execContext.resolvedSecretTraceRegistry, + }) expect(onEvent).toHaveBeenCalledWith( expect.objectContaining({ type: MothershipStreamV1EventType.tool, @@ -477,10 +597,12 @@ describe('sse-handlers tool lifecycle', () => { }) it('waits for the desktop client when a static VFS read is explicitly user-local', async () => { - waitForToolCompletion.mockResolvedValueOnce({ + waitForClientToolCompletion.mockResolvedValueOnce({ status: 'success', - data: { content: 'hello', totalLines: 1 }, + message: 'Read {{SECRET}}', + data: { content: '{{SECRET}}', totalLines: 1 }, }) + const onEvent = vi.fn() await sseHandlers.tool( { @@ -496,12 +618,32 @@ describe('sse-handlers tool lifecycle', () => { } satisfies StreamEvent, context, execContext, - { onEvent: vi.fn(), interactive: true, timeout: 1000 } + { onEvent, interactive: true, timeout: 1000 } ) await Promise.allSettled(context.pendingToolPromises.values()) - expect(waitForToolCompletion).toHaveBeenCalledWith('tool-user-local-read', 1000, undefined) + expect(waitForClientToolCompletion).toHaveBeenCalledWith({ + toolCallId: 'tool-user-local-read', + runId: context.runId, + userId: 'user-1', + timeoutMs: 1000, + abortSignal: undefined, + registry: execContext.resolvedSecretTraceRegistry, + }) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: MothershipStreamV1EventType.tool, + payload: expect.objectContaining({ + phase: MothershipStreamV1ToolPhase.result, + output: { content: '{{SECRET}}', totalLines: 1 }, + }), + }) + ) + expect(JSON.stringify(context.toolCalls.get('tool-user-local-read'))).not.toContain( + 'resolved-secret' + ) + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('resolved-secret') expect(executeTool).not.toHaveBeenCalled() }) @@ -645,6 +787,53 @@ describe('sse-handlers tool lifecycle', () => { expect(context.toolCalls.has('glob-generating')).toBe(false) }) + it('executes finalized main-tool arguments instead of a generating snapshot', async () => { + executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'function-finalized-args', + toolName: FunctionExecute.id, + arguments: { language: 'javascript', code: 'return {{STALE_SECRET}}' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + status: 'generating', + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'function-finalized-args', + toolName: FunctionExecute.id, + arguments: { language: 'javascript', code: 'return 1' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await sleep(0) + + expect(executeTool).toHaveBeenCalledWith( + FunctionExecute.id, + { language: 'javascript', code: 'return 1' }, + expect.any(Object) + ) + }) + it('updates stored params when a subagent generating event is followed by the final tool call', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) context.toolCalls.set('parent-1', { @@ -665,6 +854,7 @@ describe('sse-handlers tool lifecycle', () => { mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.call, status: 'generating', + arguments: { name: 'Stale Workflow' }, }, } satisfies StreamEvent, context, @@ -1035,10 +1225,22 @@ describe('sse-handlers tool lifecycle', () => { const firstPromise = context.pendingToolPromises.get('tool-inflight') expect(firstPromise).toBeDefined() - await sseHandlers.tool(event as StreamEvent, context, execContext, { interactive: false }) + await sseHandlers.tool( + { + ...event, + payload: { + ...event.payload, + arguments: { workflowId: 'workflow-2' }, + }, + } as StreamEvent, + context, + execContext, + { interactive: false } + ) expect(executeTool).toHaveBeenCalledTimes(1) expect(context.pendingToolPromises.get('tool-inflight')).toBe(firstPromise) + expect(context.toolCalls.get('tool-inflight')?.params).toEqual({ workflowId: 'workflow-1' }) resolveTool?.({ success: true, output: { ok: true } }) await sleep(0) diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 880edae7433..6634a625308 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -2,11 +2,8 @@ import { isBrowserToolName } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' import { isTerminalToolName } from '@sim/terminal-protocol' import { getErrorMessage, toError } from '@sim/utils/errors' -import { - ASYNC_TOOL_CONFIRMATION_STATUS, - type AsyncCompletionSignal, -} from '@/lib/copilot/async-runs/lifecycle' -import { markAsyncToolDelivered, upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' +import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' +import { upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' import { MothershipStreamV1AsyncToolRecordStatus, @@ -26,7 +23,12 @@ import { } from '@/lib/copilot/request/session' import { markToolResultSeen, wasToolResultSeen } from '@/lib/copilot/request/sse-utils' import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state' -import { executeToolAndReport, waitForToolCompletion } from '@/lib/copilot/request/tools/executor' +import { + waitForClientToolCompletion, + waitForWorkflowToolCompletion, +} from '@/lib/copilot/request/tools/client' +import { sealClientToolContext } from '@/lib/copilot/request/tools/client-completion-seal.server' +import { executeToolAndReport } from '@/lib/copilot/request/tools/executor' import { runGatedToolExecution, TOOL_AWAITING_APPROVAL_STATUS, @@ -44,7 +46,7 @@ import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' -import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' +import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools' import { getBlockByToolName } from '@/blocks/registry' import type { ToolScope } from './types' import { @@ -169,7 +171,8 @@ function rebindResolvedIntegrationCall( export async function prePersistClientExecutableToolCall( event: StreamEvent, context: StreamingContext, - options?: OrchestratorOptions + options?: OrchestratorOptions, + execContext?: ExecutionContext ): Promise { if (event.type !== 'tool') return if (!isToolCallStreamEvent(event)) return @@ -221,11 +224,30 @@ export async function prePersistClientExecutableToolCall( if (!context.runId) return + let sealedContext: Awaited> | undefined + if (execContext?.resolvedSecretTraceRegistry) { + try { + sealedContext = await sealClientToolContext({ + toolCallId: data.toolCallId, + runId: context.runId, + userId: execContext.userId, + registry: execContext.resolvedSecretTraceRegistry, + }) + } catch (error) { + execContext.resolvedSecretTraceRegistry.markIncomplete() + logger.warn('Failed to seal client tool provenance', { + toolCallId: data.toolCallId, + error: getErrorMessage(error), + }) + } + } + await upsertAsyncToolCall({ runId: context.runId, toolCallId: data.toolCallId, toolName: data.toolName, args: data.arguments, + sealedContext, // Browser and terminal actions cross a second, native authorization // boundary. Leave those rows pending until Electron atomically claims // them — the authorize endpoint only hands over a pending call, so a row @@ -399,11 +421,20 @@ async function handleCallPhase( if (isPartial && shouldDelayVfsPlaceholder(toolName, args)) return + if ( + existing && + (context.pendingToolPromises.has(toolCallId) || + existing.status === 'awaiting_approval' || + existing.status === 'executing') + ) { + applyToolDisplay(existing) + return + } + if (isSubagent) { if (wasToolResultSeen(toolCallId) || existing?.endTime) { if (!rebindResolvedIntegrationCall(existing, toolName, args)) { - if (existing && !existing.name && toolName) existing.name = toolName - if (existing && !existing.params && args) existing.params = args + if (existing) updateToolCallFromFrame(existing, toolName, args, !isPartial) } applyToolDisplay(existing) return @@ -414,8 +445,7 @@ async function handleCallPhase( (existing && existing.status !== 'pending' && existing.status !== 'executing') ) { if (!rebindResolvedIntegrationCall(existing, toolName, args)) { - if (!existing.name && toolName) existing.name = toolName - if (!existing.params && args) existing.params = args + updateToolCallFromFrame(existing, toolName, args, !isPartial) } applyToolDisplay(existing) return @@ -430,10 +460,11 @@ async function handleCallPhase( args, parentToolCallId!, ui, - spanIdentity + spanIdentity, + !isPartial ) } else { - registerMainToolCall(context, toolCallId, toolName, args, existing, ui) + registerMainToolCall(context, toolCallId, toolName, args, existing, ui, !isPartial) } if (isPartial) return @@ -507,6 +538,16 @@ function removeToolCallContentBlock(context: StreamingContext, toolCallId: strin } } +function updateToolCallFromFrame( + toolCall: ToolCallState, + toolName: string, + args: Record | undefined, + finalized: boolean +): void { + if (!toolCall.name && toolName) toolCall.name = toolName + if (finalized || args !== undefined) toolCall.params = args +} + function registerSubagentToolCall( context: StreamingContext, toolCallId: string, @@ -514,7 +555,8 @@ function registerSubagentToolCall( args: Record | undefined, parentToolCallId: string, ui: { title?: string; phaseLabel?: string; hidden?: boolean }, - spanIdentity: { spanId?: string; parentSpanId?: string } + spanIdentity: { spanId?: string; parentSpanId?: string }, + finalized: boolean ): void { if (!context.subAgentToolCalls[parentToolCallId]) { context.subAgentToolCalls[parentToolCallId] = [] @@ -523,8 +565,7 @@ function registerSubagentToolCall( let toolCall = context.toolCalls.get(toolCallId) if (toolCall) { if (!rebindResolvedIntegrationCall(toolCall, toolName, args)) { - if (!toolCall.name && toolName) toolCall.name = toolName - if (args && !toolCall.params) toolCall.params = args + updateToolCallFromFrame(toolCall, toolName, args, finalized) } applyToolDisplay(toolCall) if (hideFromUi) removeToolCallContentBlock(context, toolCallId) @@ -554,8 +595,7 @@ function registerSubagentToolCall( const existingSubagentToolCall = subagentToolCalls.find((tc) => tc.id === toolCallId) if (existingSubagentToolCall) { if (!rebindResolvedIntegrationCall(existingSubagentToolCall, toolName, args)) { - if (!existingSubagentToolCall.name && toolName) existingSubagentToolCall.name = toolName - if (args && !existingSubagentToolCall.params) existingSubagentToolCall.params = args + updateToolCallFromFrame(existingSubagentToolCall, toolName, args, finalized) } applyToolDisplay(existingSubagentToolCall) } else { @@ -569,12 +609,13 @@ function registerMainToolCall( toolName: string, args: Record | undefined, existing: ToolCallState | undefined, - ui: { title?: string; phaseLabel?: string; hidden?: boolean } + ui: { title?: string; phaseLabel?: string; hidden?: boolean }, + finalized: boolean ): void { const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true if (existing) { - if (!rebindResolvedIntegrationCall(existing, toolName, args) && args && !existing.params) { - existing.params = args + if (!rebindResolvedIntegrationCall(existing, toolName, args)) { + updateToolCallFromFrame(existing, toolName, args, finalized) } applyToolDisplay(existing) if (hideFromUi) { @@ -699,25 +740,27 @@ async function dispatchToolExecution( ...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}), }, async (span) => { - const completion = await waitForToolCompletion( - toolCallId, - options.timeout || STREAM_TIMEOUT_MS, - options.abortSignal - ) + const completion = isWorkflowToolName(toolName) + ? await waitForWorkflowToolCompletion({ + toolCallId, + workflowId: resolveWorkflowToolTargetId(args, execContext.workflowId), + timeoutMs: options.timeout || STREAM_TIMEOUT_MS, + abortSignal: options.abortSignal, + registry: execContext.resolvedSecretTraceRegistry, + }) + : await waitForClientToolCompletion({ + toolCallId, + runId: context.runId, + userId: execContext.userId, + timeoutMs: options.timeout || STREAM_TIMEOUT_MS, + abortSignal: options.abortSignal, + registry: execContext.resolvedSecretTraceRegistry, + }) span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== undefined) if (completion) { span.setAttribute(TraceAttr.ToolOutcome, completion.status) } handleClientCompletion(toolCall, toolCallId, completion) - if (completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { - await markAsyncToolDelivered(toolCallId).catch((err) => { - logger.warn(`Failed to mark background ${scopeLabel}tool delivered`, { - toolCallId, - toolName, - error: toError(err).message, - }) - }) - } await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) return ( completion ?? { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 46cfd2f19c2..de4782347ba 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -2,18 +2,11 @@ * @vitest-environment node */ -import { - environmentUtilsMockFns, - resetEnvFlagsMock, - resetEnvironmentUtilsMock, - setEnvFlags, -} from '@sim/testing' +import { resetEnvFlagsMock, resetEnvironmentUtilsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext, StreamingContext } from '@/lib/copilot/request/types' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -const mockGetEffectiveDecryptedEnv = environmentUtilsMockFns.mockGetEffectiveDecryptedEnv - afterAll(resetEnvironmentUtilsMock) const { @@ -21,6 +14,7 @@ const { mockForceFailHungToolCall, mockGetMothershipBaseURL, mockGetMothershipSourceEnvHeaders, + mockPrepareCopilotEnvironmentContext, mockPrepareExecutionContext, mockRunStreamLoop, mockPendingToolWaitBudgetMs, @@ -32,6 +26,7 @@ const { mockForceFailHungToolCall: vi.fn(), mockGetMothershipBaseURL: vi.fn(), mockGetMothershipSourceEnvHeaders: vi.fn(), + mockPrepareCopilotEnvironmentContext: vi.fn(), mockPrepareExecutionContext: vi.fn(), mockRunStreamLoop: vi.fn(), mockPendingToolWaitBudgetMs: vi.fn(() => 60_000), @@ -108,6 +103,10 @@ vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ addChatAutoAllowedTool: vi.fn(), })) +vi.mock('@/lib/copilot/environment-context', () => ({ + prepareCopilotEnvironmentContext: mockPrepareCopilotEnvironmentContext, +})) + vi.mock('@/lib/copilot/tools/handlers/context', () => ({ prepareExecutionContext: mockPrepareExecutionContext, })) @@ -147,6 +146,7 @@ describe('runCopilotLifecycle', () => { mockGetAutoAllowedTools.mockResolvedValue(new Set()) mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test') mockGetMothershipSourceEnvHeaders.mockReturnValue({}) + mockPrepareCopilotEnvironmentContext.mockResolvedValue({}) }) it('threads trace provenance through server execution context only', async () => { @@ -155,7 +155,6 @@ describe('runCopilotLifecycle', () => { userId: 'user-1', workflowId: '', workspaceId: 'ws-1', - decryptedEnvVars: {}, } let capturedExecutionContext: ExecutionContext | undefined let capturedRequestBody = '' @@ -203,7 +202,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, }, } ) @@ -257,7 +255,6 @@ describe('runCopilotLifecycle', () => { workflowId: 'wf-1', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, }, } ) @@ -277,7 +274,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -349,7 +345,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -402,7 +397,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -444,7 +438,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -480,9 +473,8 @@ describe('runCopilotLifecycle', () => { ) }) - it('propagates payload userPermission into the generated execution context', async () => { + it('does not trust payload userPermission when building the execution context', async () => { let capturedExecContext: ExecutionContext | undefined - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) mockRunStreamLoop.mockImplementationOnce( async ( _fetchUrl: string, @@ -508,9 +500,35 @@ describe('runCopilotLifecycle', () => { userId: 'user-1', workspaceId: 'ws-1', chatId: 'chat-1', - userPermission: 'write', }) ) + expect(capturedExecContext).not.toHaveProperty('userPermission') + }) + + it('uses only the trusted lifecycle userPermission option', async () => { + let capturedExecContext: ExecutionContext | undefined + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + _context: StreamingContext, + execContext: ExecutionContext + ): Promise => { + capturedExecContext = execContext + } + ) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1', userPermission: 'admin' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + userPermission: 'read', + } + ) + + expect(capturedExecContext?.userPermission).toBe('read') }) it('uses one server billing identity and immutable attribution on initial and resume legs', async () => { @@ -529,7 +547,6 @@ describe('runCopilotLifecycle', () => { setEnvFlags({ isHosted: true }) setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) mockEnv.COPILOT_API_KEY = 'sim-agent-key' - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) mockRunStreamLoop.mockImplementationOnce( async ( _fetchUrl: string, @@ -591,7 +608,6 @@ describe('runCopilotLifecycle', () => { it('runs legacy-v0 during Sim-first deployment without guessed billing aliases', async () => { setEnvFlags({ isHosted: true }) mockEnv.COPILOT_API_KEY = 'sim-agent-key' - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) await runCopilotLifecycle( { message: 'hello', messageId: 'message-1' }, @@ -626,7 +642,6 @@ describe('runCopilotLifecycle', () => { it('runs modern hosted work without legacy compatibility storage', async () => { setEnvFlags({ isHosted: true }) setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) await runCopilotLifecycle( { message: 'hello', messageId: 'message-1' }, @@ -654,7 +669,6 @@ describe('runCopilotLifecycle', () => { it('does not emit trusted billing headers for a non-hosted lifecycle', async () => { mockEnv.COPILOT_API_KEY = 'user-or-self-hosted-key' - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) await runCopilotLifecycle( { message: 'hello', messageId: 'message-1', billingRequestId: 'caller-controlled' }, @@ -685,7 +699,6 @@ describe('runCopilotLifecycle', () => { it('normalizes the initial request body with workspaceId from lifecycle options', async () => { let requestBody: Record | undefined - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) mockRunStreamLoop.mockImplementationOnce( async (_fetchUrl: string, fetchOptions: RequestInit): Promise => { requestBody = JSON.parse(String(fetchOptions.body)) @@ -716,7 +729,6 @@ describe('runCopilotLifecycle', () => { workflowId: 'workflow-1', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -775,7 +787,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } // 1) Initial stream pauses on an async tool checkpoint with a resolved @@ -857,7 +868,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } // Initial leg pauses on a resolved async tool checkpoint → enters resume. @@ -927,7 +937,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -990,7 +999,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -1052,7 +1060,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -1092,7 +1099,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } // Mirror the real helper: settle the tool call into a terminal error diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 6eb5888b630..aa22adc8829 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -1,5 +1,6 @@ import type { Context } from '@opentelemetry/api' import { createLogger } from '@sim/logger' +import type { PermissionType } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' @@ -12,6 +13,10 @@ import { import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription' import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository' import { SIM_AGENT_VERSION, TOOL_WATCHDOG_RESUME_GRACE_MS } from '@/lib/copilot/constants' +import { + type CopilotEnvironmentContext, + prepareCopilotEnvironmentContext, +} from '@/lib/copilot/environment-context' import { COPILOT_BILLING_PROTOCOL, COPILOT_BILLING_PROTOCOL_HEADER, @@ -53,6 +58,7 @@ import type { StreamEvent, StreamingContext, } from '@/lib/copilot/request/types' +import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { env } from '@/lib/core/config/env' @@ -61,7 +67,6 @@ import { isCopilotToolPermissionsEnabled, isHosted, } from '@/lib/core/config/env-flags' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('CopilotLifecycle') @@ -97,6 +102,10 @@ export interface CopilotLifecycleOptions extends OrchestratorOptions { executionContext?: ExecutionContext billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + environmentContext?: CopilotEnvironmentContext + userPermission?: PermissionType + secretMountPolicy?: SecretMountPolicy + secretActorUserId?: string | null } /** @@ -163,9 +172,14 @@ export async function runCopilotLifecycle( abortSignal: options.abortSignal, billingAttribution: options.billingAttribution ?? options.executionContext.billingAttribution, + ...(options.userPermission ? { userPermission: options.userPermission } : {}), ...(options.resolvedSecretTraceRegistry ? { resolvedSecretTraceRegistry: options.resolvedSecretTraceRegistry } : {}), + ...(options.secretMountPolicy ? { secretMountPolicy: options.secretMountPolicy } : {}), + ...(options.secretActorUserId !== undefined + ? { secretActorUserId: options.secretActorUserId } + : {}), }, } : {}), @@ -183,6 +197,10 @@ export async function runCopilotLifecycle( abortSignal: lifecycleOptions.abortSignal, billingAttribution: lifecycleOptions.billingAttribution, resolvedSecretTraceRegistry: lifecycleOptions.resolvedSecretTraceRegistry, + environmentContext: lifecycleOptions.environmentContext, + userPermission: lifecycleOptions.userPermission, + secretMountPolicy: lifecycleOptions.secretMountPolicy, + secretActorUserId: lifecycleOptions.secretActorUserId, })) const shouldUseHostedBillingProtocol = isHosted && isCopilotBillingAttributionV1Enabled if ( @@ -1000,6 +1018,10 @@ async function buildExecutionContext( abortSignal?: AbortSignal billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + environmentContext?: CopilotEnvironmentContext + userPermission?: PermissionType + secretMountPolicy?: SecretMountPolicy + secretActorUserId?: string | null } ): Promise { const { @@ -1012,27 +1034,31 @@ async function buildExecutionContext( abortSignal, billingAttribution, resolvedSecretTraceRegistry, + environmentContext, + userPermission, + secretMountPolicy, + secretActorUserId, } = params const userTimezone = typeof requestPayload?.userTimezone === 'string' ? requestPayload.userTimezone : undefined const requestMode = typeof requestPayload?.mode === 'string' ? requestPayload.mode : undefined - const userPermission = - typeof requestPayload?.userPermission === 'string' ? requestPayload.userPermission : undefined let execContext: ExecutionContext if (workflowId) { execContext = await prepareExecutionContext(userId, workflowId, chatId, { workspaceId, billingAttribution, + environmentContext, }) } else { - const decryptedEnvVars = await getEffectiveDecryptedEnv(userId, workspaceId) + const activeEnvironmentContext = + environmentContext ?? (await prepareCopilotEnvironmentContext(userId, workspaceId)) execContext = { userId, workflowId: '', workspaceId, chatId, - decryptedEnvVars, + ...activeEnvironmentContext, billingAttribution, } } @@ -1050,6 +1076,8 @@ async function buildExecutionContext( if (resolvedSecretTraceRegistry) { execContext.resolvedSecretTraceRegistry = resolvedSecretTraceRegistry } + if (secretMountPolicy) execContext.secretMountPolicy = secretMountPolicy + if (secretActorUserId !== undefined) execContext.secretActorUserId = secretActorUserId return execContext } diff --git a/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts b/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts new file mode 100644 index 00000000000..1204c5f7b92 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts @@ -0,0 +1,136 @@ +import { generateId } from '@sim/utils/id' +import { isPlainRecord } from '@sim/utils/object' +import type { AsyncCompletionData } from '@/lib/copilot/async-runs/lifecycle' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { + isResolvedSecretTraceProvenanceV1, + type ResolvedSecretTraceProvenanceV1, + type ResolvedSecretTraceRegistry, +} from '@/executor/utils/resolved-secret-trace-registry' + +export const SEALED_CLIENT_TOOL_COMPLETION_FIELD = '__sealedClientToolCompletionV1' +export const SEALED_CLIENT_TOOL_CONTEXT_FIELD = '__sealedClientToolContextV1' + +interface ClientToolBinding { + toolCallId: string + runId: string + userId: string +} + +interface ClientToolCompletionContent extends ClientToolBinding { + message?: string + data?: AsyncCompletionData +} + +interface ClientToolContext extends ClientToolBinding { + registryInstanceId: string + provenance: ResolvedSecretTraceProvenanceV1 +} + +interface SealClientToolContextInput extends ClientToolBinding { + registry: ResolvedSecretTraceRegistry +} + +type ClientCompletionSealGlobal = typeof globalThis & { + _clientToolRegistryInstanceIds?: WeakMap +} + +const sealGlobal = globalThis as ClientCompletionSealGlobal +sealGlobal._clientToolRegistryInstanceIds ??= new WeakMap() +const registryInstanceIds = sealGlobal._clientToolRegistryInstanceIds + +function getRegistryInstanceId(registry: ResolvedSecretTraceRegistry): string { + const existing = registryInstanceIds.get(registry) + if (existing) return existing + + const created = generateId() + registryInstanceIds.set(registry, created) + return created +} + +function bindingMatches(value: Record, expected: ClientToolBinding): boolean { + return ( + value.toolCallId === expected.toolCallId && + value.runId === expected.runId && + value.userId === expected.userId + ) +} + +export async function sealClientToolCompletion( + content: ClientToolCompletionContent +): Promise> { + const { encrypted } = await encryptSecret(JSON.stringify(content)) + return { [SEALED_CLIENT_TOOL_COMPLETION_FIELD]: encrypted } +} + +export async function unsealClientToolCompletion( + value: unknown, + expected: ClientToolBinding +): Promise { + if (!isPlainRecord(value)) return null + const sealed = value[SEALED_CLIENT_TOOL_COMPLETION_FIELD] + if (typeof sealed !== 'string' || sealed.length === 0) return null + + try { + const { decrypted } = await decryptSecret(sealed) + const content: unknown = JSON.parse(decrypted) + if (!isPlainRecord(content)) return null + if (!bindingMatches(content, expected)) return null + if (content.message !== undefined && typeof content.message !== 'string') return null + return { + ...expected, + ...(content.message !== undefined ? { message: content.message } : {}), + ...(Object.hasOwn(content, 'data') ? { data: content.data } : {}), + } + } catch { + return null + } +} + +export async function sealClientToolContext( + input: SealClientToolContextInput +): Promise> { + const { registry, ...binding } = input + const context: ClientToolContext = { + ...binding, + registryInstanceId: getRegistryInstanceId(registry), + provenance: registry.exportProvenance(), + } + const { encrypted } = await encryptSecret(JSON.stringify(context)) + return { [SEALED_CLIENT_TOOL_CONTEXT_FIELD]: encrypted } +} + +export function retainSealedClientToolContext( + value: unknown +): Partial> { + if (!isPlainRecord(value)) return {} + const sealed = value[SEALED_CLIENT_TOOL_CONTEXT_FIELD] + return typeof sealed === 'string' && sealed.length > 0 + ? { [SEALED_CLIENT_TOOL_CONTEXT_FIELD]: sealed } + : {} +} + +export async function unsealClientToolContext( + value: unknown, + expected: ClientToolBinding, + registry: ResolvedSecretTraceRegistry +): Promise { + if (!isPlainRecord(value)) return null + const sealed = value[SEALED_CLIENT_TOOL_CONTEXT_FIELD] + if (typeof sealed !== 'string' || sealed.length === 0) return null + + try { + const { decrypted } = await decryptSecret(sealed) + const context: unknown = JSON.parse(decrypted) + if (!isPlainRecord(context) || !bindingMatches(context, expected)) return null + if (context.registryInstanceId !== getRegistryInstanceId(registry)) return null + if (!isResolvedSecretTraceProvenanceV1(context.provenance)) return null + return { + ...expected, + registryInstanceId: context.registryInstanceId, + provenance: context.provenance, + } + } catch { + return null + } +} diff --git a/apps/sim/lib/copilot/request/tools/client.test.ts b/apps/sim/lib/copilot/request/tools/client.test.ts new file mode 100644 index 00000000000..16e798fe1ea --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/client.test.ts @@ -0,0 +1,764 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + encryptSecret, + decryptSecret, + waitForToolConfirmation, + replaceTerminalAsyncToolCallResult, + getTrustedWorkflowToolExecution, +} = vi.hoisted(() => ({ + encryptSecret: vi.fn(), + decryptSecret: vi.fn(), + waitForToolConfirmation: vi.fn(), + replaceTerminalAsyncToolCallResult: vi.fn(), + getTrustedWorkflowToolExecution: vi.fn(), +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret, + decryptSecret, +})) + +vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ + waitForToolConfirmation, +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + replaceTerminalAsyncToolCallResult, +})) + +vi.mock('@/lib/workflows/executor/execution-state', () => ({ + getTrustedWorkflowToolExecution, +})) + +import { + waitForClientToolCompletion, + waitForWorkflowToolCompletion, +} from '@/lib/copilot/request/tools/client' +import { sealClientToolContext } from '@/lib/copilot/request/tools/client-completion-seal.server' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const TRACE_SCOPE = { userId: 'user-1', workspaceId: 'workspace-1' } + +function createParentRegistry(): ResolvedSecretTraceRegistry { + const registry = new ResolvedSecretTraceRegistry( + [ + { + name: 'PARENT_SECRET', + plaintext: 'parent-secret-value', + encryptedValue: 'encrypted-parent-secret', + }, + ], + TRACE_SCOPE + ) + registry.recordResolved('PARENT_SECRET', 'parent-secret-value') + return registry +} + +function createClientRegistry(): ResolvedSecretTraceRegistry { + const registry = new ResolvedSecretTraceRegistry( + [ + { + name: 'SECRET', + plaintext: 'resolved-secret', + encryptedValue: 'encrypted-secret', + }, + ], + TRACE_SCOPE + ) + registry.recordResolved('SECRET', 'resolved-secret') + return registry +} + +function trustedExecution(executionId: string) { + return { + executionId, + workflowId: 'workflow-1', + status: 'completed' as const, + contentAvailable: true as const, + finalOutput: { value: `child read parent-secret-value from ${executionId}` }, + blockLogs: [], + provenance: { + version: 1 as const, + complete: true, + entries: [], + scope: TRACE_SCOPE, + }, + } +} + +describe('workflow client tool completion', () => { + beforeEach(() => { + vi.clearAllMocks() + decryptSecret.mockResolvedValue({ decrypted: 'child-secret-value' }) + replaceTerminalAsyncToolCallResult.mockResolvedValue({ status: 'completed' }) + }) + + it('projects a parent secret laundered through a child workflow before every live sink', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(trustedExecution('execution-1')) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(getTrustedWorkflowToolExecution).toHaveBeenCalledWith( + 'execution-1', + 'workflow-1', + 'tool-1' + ) + expect(completion).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + output: { value: 'child read {{PARENT_SECRET}} from execution-1' }, + logs: [], + }, + }) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: completion?.data, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('parent-secret-value') + expect(JSON.stringify(replaceTerminalAsyncToolCallResult.mock.calls)).not.toContain( + 'parent-secret-value' + ) + }) + + it('preserves the server-confirmed status while omitting unavailable execution content', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1', output: 'untrusted' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(null) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + expect(JSON.stringify(completion)).not.toContain('untrusted') + }) + + it('uses compacted terminal status without exposing unavailable execution content', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'failed', + contentAvailable: false, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'error', + message: 'Workflow execution failed.', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + + it('preserves cancellation when the bound terminal execution is not yet readable', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'cancelled', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(null) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'cancelled', + message: 'Workflow execution was cancelled.', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + reason: 'user_cancelled', + cancelledByUser: true, + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + + it('rejects a legacy success without a trusted execution identity', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', output: 'untrusted' }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'error', + message: 'Workflow execution failed.', + data: { success: false, workflowId: 'workflow-1' }, + }) + expect(registry.isComplete()).toBe(false) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(JSON.stringify(completion)).not.toContain('untrusted') + }) + + it('uses the bound execution status when provenance is incomplete', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + ...trustedExecution('execution-1'), + status: 'failed', + error: 'trusted failure', + provenance: { + version: 1, + complete: false, + entries: [], + scope: TRACE_SCOPE, + }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'error', + message: 'Workflow execution failed.', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + + it('imports and projects a secret activated only inside the child workflow', async () => { + const registry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + contentAvailable: true, + finalOutput: { value: 'child-secret-value' }, + blockLogs: [], + provenance: { + version: 1, + complete: true, + entries: [{ name: 'CHILD_SECRET', encryptedValue: 'encrypted-child-secret' }], + scope: TRACE_SCOPE, + }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(decryptSecret).toHaveBeenCalledWith('encrypted-child-secret') + expect(completion?.data).toEqual({ + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + output: { value: '{{CHILD_SECRET}}' }, + logs: [], + }) + expect(JSON.stringify(completion)).not.toContain('child-secret-value') + }) + + it('corrects the client terminal status from the bound execution log', async () => { + const registry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'failed', + contentAvailable: true, + error: 'trusted failure', + blockLogs: [], + provenance: { version: 1, complete: true, entries: [], scope: TRACE_SCOPE }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toMatchObject({ + status: 'error', + message: 'trusted failure', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + error: 'trusted failure', + }, + }) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'failed', + result: completion?.data, + error: 'trusted failure', + }) + }) + + it('treats background completion as structural and incomplete', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'background', + data: { + workflowId: 'workflow-1', + executionId: 'execution-1', + output: 'untrusted-background-output', + }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'background', + message: 'Workflow execution is continuing in the background.', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + expect(registry.isComplete()).toBe(false) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + + it('fails structurally when trusted child provenance cannot be imported', async () => { + const registry = createParentRegistry() + vi.spyOn(registry, 'importCrossingProvenance').mockRejectedValueOnce( + new Error('decryption unavailable') + ) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(trustedExecution('execution-1')) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(JSON.stringify(completion)).not.toContain('parent-secret-value') + }) + + it('keeps parallel workflow results safe while sibling provenance is unresolved', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockImplementation((toolCallId: string) => + Promise.resolve({ + status: 'success', + data: { + workflowId: 'workflow-1', + executionId: toolCallId === 'tool-1' ? 'execution-1' : 'execution-2', + }, + }) + ) + + const resolvers = new Map) => void>() + getTrustedWorkflowToolExecution.mockImplementation( + (executionId: string) => + new Promise((resolve) => { + resolvers.set(executionId, resolve) + }) + ) + + const firstPromise = waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + const secondPromise = waitForWorkflowToolCompletion({ + toolCallId: 'tool-2', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + await vi.waitFor(() => expect(resolvers.size).toBe(2)) + resolvers.get('execution-1')?.(trustedExecution('execution-1')) + const first = await firstPromise + + expect(first).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + + resolvers.get('execution-2')?.(trustedExecution('execution-2')) + const second = await secondPromise + + expect(second).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-2', + output: { value: 'child read {{PARENT_SECRET}} from execution-2' }, + logs: [], + }, + }) + expect(JSON.stringify([first, second])).not.toContain('parent-secret-value') + expect(JSON.stringify(replaceTerminalAsyncToolCallResult.mock.calls)).not.toContain( + 'parent-secret-value' + ) + }) +}) + +describe('generic client tool completion', () => { + beforeEach(() => { + vi.clearAllMocks() + encryptSecret.mockImplementation(async (plaintext: string) => ({ + encrypted: plaintext, + iv: 'iv', + })) + decryptSecret.mockImplementation(async (encrypted: string) => ({ + decrypted: encrypted === 'encrypted-secret' ? 'resolved-secret' : encrypted, + })) + replaceTerminalAsyncToolCallResult.mockResolvedValue({ status: 'completed' }) + }) + + it('unseals exact-bound content and provenance, then persists only the projected result', async () => { + const registry = createClientRegistry() + const sealedContext = await sealClientToolContext({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + registry, + }) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + message: 'Read resolved-secret', + data: { content: 'prefix-resolved-secret-suffix' }, + }), + ...sealedContext, + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'success', + message: 'Read {{SECRET}}', + data: { content: 'prefix-{{SECRET}}-suffix' }, + }) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: { content: 'prefix-{{SECRET}}-suffix' }, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('resolved-secret') + expect(JSON.stringify(replaceTerminalAsyncToolCallResult.mock.calls)).not.toContain( + 'resolved-secret' + ) + }) + + it('does not invalidate later tool results while a sibling activation is pending', async () => { + const registry = createClientRegistry() + const firstContext = await sealClientToolContext({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + registry, + }) + waitForToolConfirmation.mockResolvedValueOnce({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + data: { content: 'resolved-secret' }, + }), + ...firstContext, + }, + }) + + const finishSiblingActivation = registry.beginPendingActivation() + const first = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(first).toEqual({ status: 'success', message: 'Tool completed' }) + expect(registry.isPermanentlyIncomplete()).toBe(false) + finishSiblingActivation() + expect(registry.isComplete()).toBe(true) + + const secondContext = await sealClientToolContext({ + toolCallId: 'tool-2', + runId: 'run-1', + userId: 'user-1', + registry, + }) + waitForToolConfirmation.mockResolvedValueOnce({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-2', + runId: 'run-1', + userId: 'user-1', + data: { content: 'resolved-secret' }, + }), + ...secondContext, + }, + }) + + const second = await waitForClientToolCompletion({ + toolCallId: 'tool-2', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(second).toEqual({ + status: 'success', + message: 'Tool completed', + data: { content: '{{SECRET}}' }, + }) + }) + + it('fails structurally without an execution registry', async () => { + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: 'sealed-completion', + __sealedClientToolContextV1: 'sealed-context', + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + }) + + expect(completion).toEqual({ status: 'success', message: 'Tool completed' }) + expect(decryptSecret).not.toHaveBeenCalled() + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: null, + error: null, + }) + }) + + it.each([ + ['wrong tool', { toolCallId: 'other-tool', runId: 'run-1', userId: 'user-1' }], + ['wrong run', { toolCallId: 'tool-1', runId: 'other-run', userId: 'user-1' }], + ['wrong user', { toolCallId: 'tool-1', runId: 'run-1', userId: 'other-user' }], + ])('fails structurally for a completion bound to the %s', async (_label, sealedBinding) => { + const registry = createClientRegistry() + const sealedContext = await sealClientToolContext({ ...sealedBinding, registry }) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + ...sealedBinding, + data: { content: 'untrusted-secret' }, + }), + ...sealedContext, + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ status: 'success', message: 'Tool completed' }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: null, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('untrusted-secret') + }) + + it('fails structurally when a restarted execution uses a new registry instance', async () => { + const sourceRegistry = createClientRegistry() + const resumedRegistry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + const sealedContext = await sealClientToolContext({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + registry: sourceRegistry, + }) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + data: { content: 'resolved-secret' }, + }), + ...sealedContext, + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry: resumedRegistry, + }) + + expect(completion).toEqual({ status: 'success', message: 'Tool completed' }) + expect(resumedRegistry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: null, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('resolved-secret') + }) + + it('fails structurally for a legacy raw confirmation without sealed provenance', async () => { + const registry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + waitForToolConfirmation.mockResolvedValue({ + status: 'error', + message: 'raw error secret', + data: { content: 'raw result secret' }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ status: 'error', message: 'Tool result omitted' }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'failed', + result: null, + error: 'Tool result omitted', + }) + expect(JSON.stringify(completion)).not.toContain('raw') + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/client.ts b/apps/sim/lib/copilot/request/tools/client.ts index 43c42de6a8c..f6c7ebede0f 100644 --- a/apps/sim/lib/copilot/request/tools/client.ts +++ b/apps/sim/lib/copilot/request/tools/client.ts @@ -1,10 +1,29 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isPlainRecord } from '@sim/utils/object' import { ASYNC_TOOL_CONFIRMATION_STATUS, type AsyncTerminalCompletionSnapshot, isAsyncTerminalConfirmationStatus, } from '@/lib/copilot/async-runs/lifecycle' +import { replaceTerminalAsyncToolCallResult } from '@/lib/copilot/async-runs/repository' import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' import { waitForToolConfirmation } from '@/lib/copilot/persistence/tool-confirm' +import { + unsealClientToolCompletion, + unsealClientToolContext, +} from '@/lib/copilot/request/tools/client-completion-seal.server' +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' +import { + createStructuralWorkflowToolCompletionData, + getWorkflowToolCompletionExecutionId, + getWorkflowToolCompletionMessage, + getWorkflowToolConfirmationStatus, +} from '@/lib/copilot/tools/workflow-tools' +import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const logger = createLogger('CopilotClientToolWaiter') /** * Wait for a client-executable workflow tool to report back. @@ -31,3 +50,307 @@ export async function waitForToolCompletion( } return null } + +interface WaitForClientToolCompletionOptions { + toolCallId: string + runId?: string + userId: string + timeoutMs: number + abortSignal?: AbortSignal + registry?: ResolvedSecretTraceRegistry +} + +function getGenericCompletionMessage(status: AsyncTerminalCompletionSnapshot['status']): string { + if (status === MothershipStreamV1ToolOutcome.success) return 'Tool completed' + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) return 'Tool is running in background' + if (status === MothershipStreamV1ToolOutcome.cancelled) return 'Tool cancelled' + return 'Tool failed' +} + +/** + * Restores a generic browser/terminal result from its sealed transport envelope, + * projects active Secrets values, then replaces the durable row before delivery. + */ +export async function waitForClientToolCompletion({ + toolCallId, + runId, + userId, + timeoutMs, + abortSignal, + registry, +}: WaitForClientToolCompletionOptions): Promise { + const completion = await waitForToolCompletion(toolCallId, timeoutMs, abortSignal) + if (!completion) return null + + const genericMessage = getGenericCompletionMessage(completion.status) + const binding = runId ? { toolCallId, runId, userId } : undefined + const registryCanImport = registry !== undefined && !registry.isPermanentlyIncomplete() + const finishPendingActivation = registry?.beginPendingActivation() + let content: Awaited> = null + try { + const [sealedContent, sealedContext] = + binding && registry && registryCanImport + ? await Promise.all([ + unsealClientToolCompletion(completion.data, binding), + unsealClientToolContext(completion.data, binding, registry), + ]) + : [null, null] + if (registry && registryCanImport) { + if (!sealedContent || !sealedContext) { + registry.markIncomplete() + } else { + const imported = await registry.importProvenance(sealedContext.provenance, { + trusted: true, + }) + if (!imported || !sealedContext.provenance.complete) { + registry.markIncomplete() + } else { + content = sealedContent + } + } + } + } catch { + registry?.markIncomplete() + } finally { + finishPendingActivation?.() + } + if (!registry?.isComplete()) content = null + + const rawOutput: Record = { + ...(content?.message !== undefined ? { message: content.message } : {}), + ...(content && Object.hasOwn(content, 'data') ? { data: content.data } : {}), + } + const succeeded = completion.status === MothershipStreamV1ToolOutcome.success + const projected = projectToolResultForCopilot( + { + success: succeeded, + output: rawOutput, + ...(!succeeded ? { error: content?.message ?? genericMessage } : {}), + }, + registry + ) + const projectedOutput = isPlainRecord(projected.output) ? projected.output : undefined + const message = + typeof projectedOutput?.message === 'string' + ? projectedOutput.message + : !succeeded && projected.error + ? projected.error + : genericMessage + const data = + projectedOutput && Object.hasOwn(projectedOutput, 'data') ? projectedOutput.data : undefined + + if (completion.status !== ASYNC_TOOL_CONFIRMATION_STATUS.background) { + const status = + completion.status === MothershipStreamV1ToolOutcome.success + ? 'completed' + : completion.status === MothershipStreamV1ToolOutcome.cancelled + ? 'cancelled' + : 'failed' + try { + const updated = await replaceTerminalAsyncToolCallResult({ + toolCallId, + status, + result: data ?? null, + error: succeeded ? null : message, + }) + if (!updated) { + logger.warn('Client tool row was no longer terminal during safe payload update', { + toolCallId, + }) + } + } catch (error) { + logger.warn('Failed to persist projected client tool result', { + toolCallId, + error: getErrorMessage(error), + }) + } + } + + return { + status: completion.status, + message, + ...(data !== undefined ? { data } : {}), + } +} + +interface WaitForWorkflowToolCompletionOptions { + toolCallId: string + workflowId?: string + timeoutMs: number + abortSignal?: AbortSignal + registry?: ResolvedSecretTraceRegistry +} + +function structuralWorkflowCompletion( + status: AsyncTerminalCompletionSnapshot['status'], + workflowId?: string, + executionId?: string +): AsyncTerminalCompletionSnapshot { + return { + status, + message: getWorkflowToolCompletionMessage(status), + data: createStructuralWorkflowToolCompletionData(status, workflowId, executionId), + } +} + +/** + * Restores a client-run workflow result from the bound server execution log. + * The browser confirmation is only a wakeup and structural identity carrier. + */ +export async function waitForWorkflowToolCompletion({ + toolCallId, + workflowId, + timeoutMs, + abortSignal, + registry, +}: WaitForWorkflowToolCompletionOptions): Promise { + const finishPendingActivation = registry?.beginPendingActivation() + let completion: AsyncTerminalCompletionSnapshot | null = null + let trustedExecution: Awaited> = null + + try { + completion = await waitForToolCompletion(toolCallId, timeoutMs, abortSignal) + if (!completion) { + registry?.markIncomplete() + return null + } + + const executionId = getWorkflowToolCompletionExecutionId(completion.data) + if (completion.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + registry?.markIncomplete() + return structuralWorkflowCompletion(completion.status, workflowId, executionId) + } + if (!workflowId || !executionId) { + registry?.markIncomplete() + const structuralStatus = + completion.status === MothershipStreamV1ToolOutcome.success + ? MothershipStreamV1ToolOutcome.error + : completion.status + return structuralWorkflowCompletion(structuralStatus, workflowId, executionId) + } + + try { + trustedExecution = await getTrustedWorkflowToolExecution(executionId, workflowId, toolCallId) + } catch (error) { + logger.warn('Failed to restore bound workflow tool execution', { + toolCallId, + workflowId, + executionId, + error: getErrorMessage(error), + }) + } + + if (!trustedExecution) { + registry?.markIncomplete() + return structuralWorkflowCompletion(completion.status, workflowId, executionId) + } + + if (!trustedExecution.contentAvailable) { + registry?.markIncomplete() + return structuralWorkflowCompletion( + getWorkflowToolConfirmationStatus(trustedExecution.status), + workflowId, + executionId + ) + } + + if (!registry || registry.isPermanentlyIncomplete() || !trustedExecution.provenance.complete) { + if (!trustedExecution.provenance.complete) registry?.markIncomplete() + return structuralWorkflowCompletion( + getWorkflowToolConfirmationStatus(trustedExecution.status), + workflowId, + executionId + ) + } + + try { + const imported = await registry.importCrossingProvenance( + trustedExecution.provenance, + { + ...(Object.hasOwn(trustedExecution, 'finalOutput') + ? { finalOutput: trustedExecution.finalOutput } + : {}), + blockLogs: trustedExecution.blockLogs, + ...(trustedExecution.error !== undefined ? { error: trustedExecution.error } : {}), + }, + { trusted: true } + ) + if (!imported) registry.markIncomplete() + } catch (error) { + registry.markIncomplete() + logger.warn('Failed to import bound workflow provenance', { + toolCallId, + workflowId, + executionId, + error: getErrorMessage(error), + }) + } + } finally { + finishPendingActivation?.() + } + + if (!completion || !trustedExecution || !workflowId) return completion + + const executionId = trustedExecution.executionId + const status = getWorkflowToolConfirmationStatus(trustedExecution.status) + const genericMessage = getWorkflowToolCompletionMessage(status) + const rawData: Record = { + success: status === MothershipStreamV1ToolOutcome.success, + workflowId, + executionId, + ...(Object.hasOwn(trustedExecution, 'finalOutput') + ? { output: trustedExecution.finalOutput } + : {}), + logs: trustedExecution.blockLogs, + ...(trustedExecution.error !== undefined ? { error: trustedExecution.error } : {}), + ...(status === MothershipStreamV1ToolOutcome.cancelled + ? { reason: 'user_cancelled', cancelledByUser: true } + : {}), + } + const projected = projectToolResultForCopilot( + { + success: status === MothershipStreamV1ToolOutcome.success, + output: rawData, + ...(status !== MothershipStreamV1ToolOutcome.success + ? { error: trustedExecution.error ?? genericMessage } + : {}), + }, + registry + ) + const projectedData = isPlainRecord(projected.output) ? projected.output : {} + const data = { + ...projectedData, + ...createStructuralWorkflowToolCompletionData(status, workflowId, executionId), + } + const message = + status === MothershipStreamV1ToolOutcome.success + ? genericMessage + : Object.hasOwn(projected, 'output') && projected.error + ? projected.error + : genericMessage + + try { + const updated = await replaceTerminalAsyncToolCallResult({ + toolCallId, + status: trustedExecution.status, + result: data, + error: status === MothershipStreamV1ToolOutcome.success ? null : message, + }) + if (!updated) { + logger.warn('Bound workflow tool row was no longer terminal during safe payload update', { + toolCallId, + workflowId, + executionId, + }) + } + } catch (error) { + logger.warn('Failed to persist projected workflow tool result', { + toolCallId, + workflowId, + executionId, + error: getErrorMessage(error), + }) + } + + return { status, message, data } +} diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index 656feb72663..8d60e889a05 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -3,9 +3,11 @@ import '@sim/testing/mocks/executor' import { describe, expect, it } from 'vitest' import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants' import { + buildToolExecutionContext, pendingToolWaitBudgetMs, toolWatchdogTimeoutMs, } from '@/lib/copilot/request/tools/executor' +import type { ExecutionContext } from '@/lib/copilot/request/types' describe('toolWatchdogTimeoutMs', () => { it('gives request-scoped MCP tools the long-running watchdog', () => { @@ -32,3 +34,27 @@ describe('pendingToolWaitBudgetMs', () => { ) }) }) + +describe('buildToolExecutionContext', () => { + it('threads logical tool-call identity into the handler context', () => { + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + runId: 'run-1', + } + + expect( + buildToolExecutionContext( + { + id: 'call-1', + parentToolCallId: 'parent-1', + }, + executionContext + ) + ).toMatchObject({ + runId: 'run-1', + toolCallId: 'call-1', + parentToolCallId: 'parent-1', + }) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index fd59ba2fa8b..10f0f84402c 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -53,6 +53,7 @@ import { setTerminalToolCallState, } from '@/lib/copilot/request/tool-call-state' import { maybeWriteOutputToFile } from '@/lib/copilot/request/tools/files' +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' import { maybeWriteOutputToTable, @@ -254,6 +255,18 @@ class ToolExecutionTimeoutError extends Error { } } +/** Builds the per-call context from the turn-scoped execution context. */ +export function buildToolExecutionContext( + toolCall: Pick, + execContext: ExecutionContext +): ExecutionContext { + return { + ...execContext, + toolCallId: toolCall.id, + ...(toolCall.parentToolCallId ? { parentToolCallId: toolCall.parentToolCallId } : {}), + } +} + /** * Execute a tool with a hard settlement guarantee. If the handler neither * resolves nor rejects within the tool's watchdog cap, throw a timeout error @@ -264,12 +277,7 @@ class ToolExecutionTimeoutError extends Error { */ async function executeToolWithWatchdog(toolCall: ToolCallState, execContext: ExecutionContext) { const timeoutMs = toolWatchdogTimeoutMs(toolCall.name) - // Thread the invoking subagent's channel id per call (execContext is shared - // across the whole turn, so the channel id can't live on it) — server tools - // use it to scope the workspace_file -> edit_content intent handoff. - const toolContext = toolCall.parentToolCallId - ? { ...execContext, parentToolCallId: toolCall.parentToolCallId } - : execContext + const toolContext = buildToolExecutionContext(toolCall, execContext) const execution = executeTool(toolCall.name, toolCall.params || {}, toolContext) let timer: ReturnType | undefined try { @@ -558,6 +566,10 @@ async function executeToolAndReportInner( return terminalCompletionFromToolCall(toolCall) } if (abortRequested(context, execContext, options)) { + const copilotResult = projectToolResultForCopilot( + result, + execContext.resolvedSecretTraceRegistry + ) markToolCallCancelled('Request aborted during tool execution') markToolResultSeen(toolCall.id) await completeAsyncToolCall({ @@ -579,7 +591,7 @@ async function executeToolAndReportInner( }) endToolSpan('cancelled', { cancelReason: 'abort_during_execution', - error: result.success === false ? result.error : undefined, + error: copilotResult.success === false ? copilotResult.error : undefined, }) return cancelledCompletion('Request aborted during tool execution') } @@ -655,17 +667,22 @@ async function executeToolAndReportInner( endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_csv' }) return cancelledCompletion('Request aborted during tool post-processing') } + const copilotResult = projectToolResultForCopilot( + result, + execContext.resolvedSecretTraceRegistry + ) + toolSpan.attributes = { ...toolSpan.attributes, - ...summarizeToolResultForSpan(result), + ...summarizeToolResultForSpan(copilotResult), } setTerminalToolCallState(toolCall, { - status: result.success + status: copilotResult.success ? MothershipStreamV1ToolOutcome.success : MothershipStreamV1ToolOutcome.error, - ...(hasOutputValue(result) ? { output: result.output } : {}), - ...(result.success ? {} : { error: result.error || 'Tool failed' }), + ...(hasOutputValue(copilotResult) ? { output: copilotResult.output } : {}), + ...(copilotResult.success ? {} : { error: copilotResult.error || 'Tool failed' }), }) if (result.success) { @@ -688,7 +705,7 @@ async function executeToolAndReportInner( logger.warn('Tool execution failed', { toolCallId: toolCall.id, toolName: toolCall.name, - error: result.error, + error: copilotResult.error, params: toolCall.params, }) } @@ -741,7 +758,7 @@ async function executeToolAndReportInner( mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.result, success: result.success, - output: result.output, + output: copilotResult.output, ...(result.success ? { status: MothershipStreamV1ToolOutcome.success } : { status: MothershipStreamV1ToolOutcome.error }), @@ -760,6 +777,7 @@ async function executeToolAndReportInner( toolCall.name, toolCall.params, result, + copilotResult, execContext.chatId, options?.onEvent, () => abortRequested(context, execContext, options) @@ -776,6 +794,11 @@ async function executeToolAndReportInner( }) } catch (error) { const thrownMessage = toError(error).message + const copilotError = projectToolResultForCopilot( + { success: false, error: thrownMessage }, + execContext.resolvedSecretTraceRegistry + ) + const safeThrownMessage = copilotError.error || 'Tool failed' if (abortRequested(context, execContext, options)) { markToolCallCancelled('Request aborted during tool execution') markToolResultSeen(toolCall.id) @@ -798,13 +821,13 @@ async function executeToolAndReportInner( }) endToolSpan('cancelled', { cancelReason: 'abort_during_execution_catch', - error: thrownMessage, + error: safeThrownMessage, }) return cancelledCompletion('Request aborted during tool execution') } setTerminalToolCallState(toolCall, { status: MothershipStreamV1ToolOutcome.error, - error: thrownMessage, + error: safeThrownMessage, }) logger.error('Tool execution threw', { @@ -848,7 +871,7 @@ async function executeToolAndReportInner( }, } await options?.onEvent?.(errorEvent) - endToolSpan('error', { error: thrownMessage }) + endToolSpan('error', { error: safeThrownMessage }) return buildCompletionSignal({ status: MothershipStreamV1ToolOutcome.error, message: toolCall.error, diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 5fe59ef72a2..048fff75196 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -7,6 +7,7 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions' +import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' @@ -342,14 +343,18 @@ export async function maybeWriteOutputToFile( } } catch (err) { const message = toError(err).message + const projectedMessage = projectToolErrorMessageForCopilot( + message, + context.resolvedSecretTraceRegistry + ) logger.warn('Failed to write tool output to file', { toolName, outputPaths: outputFiles.map((file) => file.path), - error: message, + error: projectedMessage, }) span.setAttribute(TraceAttr.CopilotOutputFileOutcome, CopilotOutputFileOutcome.Failed) span.addEvent(TraceEvent.CopilotOutputFileError, { - [TraceAttr.ErrorMessage]: message.slice(0, 500), + [TraceAttr.ErrorMessage]: projectedMessage.slice(0, 500), }) return { success: false, diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index 78096cdc61b..5c75b645730 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -33,7 +33,10 @@ import type { StreamEvent, ToolCallState } from '@/lib/copilot/request/types' function makeContext() { const context = createStreamingContext({ runId: 'run-1' }) - context.toolPermissions = { enabled: true, autoAllowed: new Set() } + context.toolPermissions = { + enabled: true, + autoAllowed: new Set(), + } context.trace = new TraceCollector() return context } @@ -91,6 +94,30 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) + it.each(['deploy_api', 'deploy_chat', 'deploy_mcp'])( + 'honors the saved permission for a %s undeploy', + (toolName) => { + const context = makeContext() + context.toolPermissions.autoAllowed.add(toolName) + + expect(toolCallNeedsApproval(toolName, context, {}, false, { action: 'undeploy' })).toBe( + false + ) + } + ) + + it('applies the normal saved permission to code with a secret reference', () => { + const context = makeContext() + context.toolPermissions.autoAllowed.add('function_execute') + + expect( + toolCallNeedsApproval('function_execute', context, {}, false, { + language: 'javascript', + code: 'return {{API_KEY}}', + }) + ).toBe(false) + }) + it('never gates a non-interactive run, which has nobody to answer the prompt', () => { expect( toolCallNeedsApproval('terminal', makeContext(), { interactive: false }, false, runCall) @@ -103,6 +130,18 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) + it('does not add a secret-specific gate when the permission feature is off', () => { + const context = makeContext() + context.toolPermissions.enabled = false + + expect( + toolCallNeedsApproval('function_execute', context, {}, false, { + language: 'javascript', + code: 'return {{API_KEY}}', + }) + ).toBe(false) + }) + it('gates a resolved integration operation off the frame Go stamped', () => { // gmail_read_v2 is request-local: it is not in the catalog at all, so the // only thing marking it is the awaiting_approval status on the frame. @@ -281,6 +320,23 @@ describe('runGatedToolExecution', () => { expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(true) }) + it('accepts the normal chat-level decision for code with a secret reference', async () => { + const context = makeContext() + const toolCall = makeToolCall() + toolCall.name = 'function_execute' + toolCall.params = { language: 'javascript', code: 'return {{API_KEY}}' } + const execute = vi.fn().mockResolvedValue({ status: 'success' }) + waitForToolPermissionDecision.mockResolvedValue({ + toolCallId: 'call-1', + decision: 'allow_chat', + }) + + await gate(context, toolCall, execute, []) + + expect(execute).toHaveBeenCalledTimes(1) + expect(context.toolPermissions.autoAllowed.has('function_execute')).toBe(true) + }) + it('does not suppress later prompts for a one-off allow', async () => { const context = makeContext() const toolCall = makeToolCall() diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts new file mode 100644 index 00000000000..fc8a307b05b --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts @@ -0,0 +1,219 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { FunctionExecute, RunCode } from '@/lib/copilot/generated/tool-catalog-v1' +import { + projectToolResultForCopilot, + TOOL_RESULT_OMITTED_ERROR, +} from '@/lib/copilot/request/tools/resolved-secret-result' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +function createRegistry(): ResolvedSecretTraceRegistry { + return new ResolvedSecretTraceRegistry([ + { + name: 'SECRET', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ]) +} + +describe('projectToolResultForCopilot', () => { + it.each([FunctionExecute.id, RunCode.id])( + 'projects active exact and embedded secrets for %s without mutating runtime output', + (toolName) => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const runtimeResult = { + success: true, + output: { + result: 'secret-value', + stdout: 'prefix-secret-value-suffix', + values: ['safe', 'secret-value'], + }, + } + const runtimeSnapshot = structuredClone(runtimeResult) + + expect(projectToolResultForCopilot(runtimeResult, registry)).toEqual({ + success: true, + output: { + result: '{{SECRET}}', + stdout: 'prefix-{{SECRET}}-suffix', + values: ['safe', '{{SECRET}}'], + }, + }) + expect(runtimeResult).toEqual(runtimeSnapshot) + } + ) + + it('projects both output and error from a failed Function execution', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + + expect( + projectToolResultForCopilot( + { + success: false, + output: { stdout: 'printed secret-value' }, + error: 'Function failed near secret-value', + }, + registry + ) + ).toEqual({ + success: false, + output: { stdout: 'printed {{SECRET}}' }, + error: 'Function failed near {{SECRET}}', + }) + }) + + it('projects secret-bearing object keys and omits content when replacement collides', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + + expect( + projectToolResultForCopilot( + { + success: true, + output: { 'prefix-secret-value': 'safe' }, + }, + registry + ) + ).toEqual({ + success: true, + output: { 'prefix-{{SECRET}}': 'safe' }, + }) + + expect( + projectToolResultForCopilot( + { + success: true, + output: { 'secret-value': 'first', '{{SECRET}}': 'second' }, + }, + registry + ) + ).toEqual({ + success: true, + }) + }) + + it('omits content when one replacement creates another active literal', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'MIDDLE', plaintext: 'B', encryptedValue: 'encrypted-b' }, + { name: 'BRACE', plaintext: '{', encryptedValue: 'encrypted-brace' }, + { name: 'JOINED', plaintext: 'ac', encryptedValue: 'encrypted-ac' }, + ]) + registry.recordResolved('MIDDLE', 'B') + registry.recordResolved('BRACE', '{') + registry.recordResolved('JOINED', 'ac') + + expect(projectToolResultForCopilot({ success: true, output: 'aBc' }, registry)).toEqual({ + success: true, + }) + }) + + it('keeps the control error safe from active one-character values', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'F_SECRET', plaintext: 'F', encryptedValue: 'encrypted-f' }, + ]) + registry.recordResolved('F_SECRET', 'F') + + const projected = projectToolResultForCopilot( + { + success: false, + output: { F: 'first', '': 'second' }, + error: 'F', + }, + registry + ) + + expect(projected.success).toBe(false) + expect(projected).not.toHaveProperty('output') + expect(projected.error).toBeTruthy() + expect(projected.error).not.toContain('F') + }) + + it('does not project transformed values', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const encoded = Buffer.from('secret-value').toString('base64') + + expect( + projectToolResultForCopilot({ success: true, output: { result: encoded } }, registry) + ).toEqual({ success: true, output: { result: encoded } }) + }) + + it('leaves configured but unused values unchanged', () => { + const registry = createRegistry() + const result = { + success: true, + output: { result: 'secret-value', stdout: '' }, + } + + expect(projectToolResultForCopilot(result, registry)).toEqual(result) + }) + + it.each([ + ['missing', undefined], + [ + 'incomplete', + (() => { + const registry = createRegistry() + registry.markIncomplete() + return registry + })(), + ], + ])('fails closed for %s provenance without changing structural fields', (_label, registry) => { + expect( + projectToolResultForCopilot( + { + success: false, + output: { result: 'possibly-secret' }, + error: 'possibly-secret-error', + resources: [{ type: 'file', id: 'file-1', title: 'report.txt' }], + }, + registry + ) + ).toEqual({ + success: false, + error: TOOL_RESULT_OMITTED_ERROR, + }) + }) + + it('projects Copilot-visible resource metadata without changing the runtime result', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const result = { + success: true, + resources: [{ type: 'file' as const, id: 'file-secret-value', title: 'secret-value.txt' }], + } + + expect(projectToolResultForCopilot(result, registry)).toEqual({ + success: true, + resources: [{ type: 'file', id: 'file-secret-value', title: '{{SECRET}}.txt' }], + }) + expect(result.resources[0]).toEqual({ + type: 'file', + id: 'file-secret-value', + title: 'secret-value.txt', + }) + }) + + it('projects every tool result once provenance is active', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const result = { success: true, output: 'secret-value' } + + expect(projectToolResultForCopilot(result, registry)).toEqual({ + success: true, + output: '{{SECRET}}', + }) + expect(result).toEqual({ success: true, output: 'secret-value' }) + }) + + it('omits every tool result when no trusted provenance registry exists', () => { + expect( + projectToolResultForCopilot({ success: true, output: 'possibly-secret' }, undefined) + ).toEqual({ success: true }) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts new file mode 100644 index 00000000000..b7a3b1f4e89 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts @@ -0,0 +1,139 @@ +import { isPlainRecord, omit } from '@sim/utils/object' +import type { MothershipResource } from '@/lib/copilot/resources/types' +import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { + containsResolvedSecret, + createResolvedSecretMatcher, + projectResolvedSecretContent, + type ResolvedSecretMatcher, + sanitizeResolvedSecretString, +} from '@/executor/utils/resolved-secret-content-projection' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +export const TOOL_RESULT_OMITTED_ERROR = 'Tool result omitted' + +function omitContent(result: ToolExecutionResult): ToolExecutionResult { + return omit(result, ['output', 'error', 'resources']) +} + +function resourceContent(resources: MothershipResource[]): Array<{ title: string; path?: string }> { + return resources.map((resource) => ({ + title: resource.title, + ...(resource.path !== undefined ? { path: resource.path } : {}), + })) +} + +function restoreProjectedResources( + resources: MothershipResource[], + projectedContent: unknown +): MothershipResource[] | undefined { + if (!Array.isArray(projectedContent) || projectedContent.length !== resources.length) { + return undefined + } + + const projectedResources: MothershipResource[] = [] + for (let index = 0; index < resources.length; index += 1) { + const content = projectedContent[index] + if ( + !isPlainRecord(content) || + typeof content.title !== 'string' || + (content.path !== undefined && typeof content.path !== 'string') + ) { + return undefined + } + + const resource = resources[index] + projectedResources.push({ + type: resource.type, + id: resource.id, + title: content.title, + ...(content.path !== undefined ? { path: content.path } : {}), + }) + } + + return projectedResources +} + +/** Returns a nonempty control error that cannot contain any active literal. */ +function createSafeControlError(matcher: ResolvedSecretMatcher | undefined): string { + if (!matcher) return TOOL_RESULT_OMITTED_ERROR + + try { + const projected = sanitizeResolvedSecretString(TOOL_RESULT_OMITTED_ERROR, matcher) + if (projected.length > 0 && !containsResolvedSecret(projected, matcher)) return projected + } catch {} + + for (let codePoint = 0x21; codePoint <= 0x10ffff; codePoint += 1) { + if (codePoint >= 0xd800 && codePoint <= 0xdfff) { + codePoint = 0xdfff + continue + } + const candidate = String.fromCodePoint(codePoint) + if (!containsResolvedSecret(candidate, matcher)) return candidate + } + + throw new Error('Active secret matcher covers every Unicode scalar') +} + +function omittedResult( + result: ToolExecutionResult, + matcher: ResolvedSecretMatcher | undefined +): ToolExecutionResult { + const structural = omitContent(result) + return result.success ? structural : { ...structural, error: createSafeControlError(matcher) } +} + +/** + * Projects terminal tool content before it can cross back into Copilot. + * Runtime output remains unchanged for raw post-processing and context updates. + */ +export function projectToolResultForCopilot( + result: ToolExecutionResult, + registry: ResolvedSecretTraceRegistry | undefined +): ToolExecutionResult { + if (!registry?.isComplete()) return omittedResult(result, undefined) + + let matcher: ResolvedSecretMatcher | undefined + try { + matcher = createResolvedSecretMatcher(registry.getActiveMatches()) + if (!matcher) return result + + const content: Record = {} + if (Object.hasOwn(result, 'output')) content.output = result.output + if (Object.hasOwn(result, 'error')) content.error = result.error + if (result.resources !== undefined) content.resources = resourceContent(result.resources) + const projection = projectResolvedSecretContent(content, matcher) + if (!projection.safe || !projection.value || typeof projection.value !== 'object') { + return omittedResult(result, matcher) + } + + const projectedContent = projection.value as Record + const projected = omitContent(result) + if (Object.hasOwn(projectedContent, 'output')) projected.output = projectedContent.output + if (Object.hasOwn(projectedContent, 'error')) { + projected.error = String(projectedContent.error) + } + if (result.resources !== undefined) { + const resources = restoreProjectedResources(result.resources, projectedContent.resources) + if (!resources) return omittedResult(result, matcher) + projected.resources = resources + } + if (!projected.success && !projected.error) { + projected.error = createSafeControlError(matcher) + } + return projected + } catch { + return omittedResult(result, matcher) + } +} + +/** Projects an error before post-processing can attach it to application logs or OTel events. */ +export function projectToolErrorMessageForCopilot( + error: string, + registry: ResolvedSecretTraceRegistry | undefined +): string { + return ( + projectToolResultForCopilot({ success: false, error }, registry).error ?? + TOOL_RESULT_OMITTED_ERROR + ) +} diff --git a/apps/sim/lib/copilot/request/tools/resources.ts b/apps/sim/lib/copilot/request/tools/resources.ts index 361f4105201..88ee1f01a07 100644 --- a/apps/sim/lib/copilot/request/tools/resources.ts +++ b/apps/sim/lib/copilot/request/tools/resources.ts @@ -29,6 +29,7 @@ export async function handleResourceSideEffects( toolName: string, params: Record | undefined, result: ToolCallResult, + projectedResult: ToolCallResult, chatId: string, onEvent: ((event: StreamEvent) => void | Promise) | undefined, isAborted: () => boolean @@ -57,6 +58,11 @@ export async function handleResourceSideEffects( if (hasDeleteCapability(toolName)) { const deleted = extractDeletedResourcesFromToolResult(toolName, params, result.output) + const projectedDeleted = extractDeletedResourcesFromToolResult( + toolName, + params, + projectedResult.output + ) if (deleted.length > 0) { isDeleteOp = true removedCount = deleted.length @@ -71,13 +77,19 @@ export async function handleResourceSideEffects( }) }) - for (const resource of deleted) { + for (let index = 0; index < deleted.length; index += 1) { if (isAborted()) break + const resource = deleted[index] + const projected = projectedDeleted[index] await onEvent?.({ type: MothershipStreamV1EventType.resource, payload: { op: MothershipStreamV1ResourceOp.remove, - resource: { type: resource.type, id: resource.id, title: resource.title }, + resource: { + type: resource.type, + id: resource.id, + title: projected?.title ?? '', + }, }, }) } @@ -85,12 +97,29 @@ export async function handleResourceSideEffects( } if (!isDeleteOp && !isAborted()) { - const resources = + const rawResources = result.resources && result.resources.length > 0 ? result.resources : isResourceToolName(toolName) ? extractResourcesFromToolResult(toolName, params, result.output) : [] + const projectedResources = + result.resources && result.resources.length > 0 + ? (projectedResult.resources ?? []) + : isResourceToolName(toolName) + ? extractResourcesFromToolResult(toolName, params, projectedResult.output) + : [] + const resources = + projectedResources.length === rawResources.length + ? rawResources.map((resource, index) => ({ + type: resource.type, + id: resource.id, + title: projectedResources[index].title, + ...(projectedResources[index].path !== undefined + ? { path: projectedResources[index].path } + : {}), + })) + : [] if (resources.length > 0) { upsertedCount = resources.length diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index c9ab80ae41b..f90edf31e66 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -2,12 +2,14 @@ * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' -const { mockGetTableById, mockReplaceTableRows } = vi.hoisted(() => ({ +const { mockGetTableById, mockReplaceTableRows, mockSpanAddEvent } = vi.hoisted(() => ({ mockGetTableById: vi.fn(), mockReplaceTableRows: vi.fn(), + mockSpanAddEvent: vi.fn(), })) vi.mock('@/lib/table/service', () => ({ @@ -23,7 +25,7 @@ vi.mock('@/lib/copilot/request/otel', () => ({ _name: string, _attrs: Record | undefined, fn: (span: unknown) => Promise - ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn() }), + ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: mockSpanAddEvent }), })) import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' @@ -32,6 +34,13 @@ import { maybeWriteReadCsvToTable, } from '@/lib/copilot/request/tools/tables' import type { ExecutionContext } from '@/lib/copilot/request/types' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const tableLogger = vi.mocked(loggerMock.createLogger).mock.results[ + vi + .mocked(loggerMock.createLogger) + .mock.calls.findIndex(([name]) => name === 'CopilotToolResultTables') +]?.value function buildTable(overrides: Partial = {}): TableDefinition { return { @@ -172,6 +181,27 @@ describe('maybeWriteOutputToTable', () => { expect(result.success).toBe(false) expect(result.error).toContain('Row 1: name is required') }) + + it('keeps raw errors for terminal projection but projects application logs and OTel events', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }, + ]) + registry.recordResolved('SECRET', 'secret-value') + mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"')) + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { success: true, output: { result: [{ name: 'secret-value' }] } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.error).toContain('secret-value') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') + }) }) describe('maybeWriteReadCsvToTable', () => { @@ -251,4 +281,25 @@ describe('maybeWriteReadCsvToTable', () => { expect(result.success).toBe(false) expect(result.error).toContain('Row 1: name is required') }) + + it('projects active secret literals in CSV-import log and OTel errors', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }, + ]) + registry.recordResolved('SECRET', 'secret-value') + mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"')) + + const result = await maybeWriteReadCsvToTable( + ReadTool.id, + { outputTable: 'tbl_1', path: 'files/people.csv' }, + { success: true, output: { content: 'name\nsecret-value' } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.error).toContain('secret-value') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') + }) }) diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index 5d1aaf310a3..053c37de141 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -9,6 +9,7 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions' +import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import type { RowData, TableDefinition } from '@/lib/table' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' @@ -151,18 +152,23 @@ export async function maybeWriteOutputToTable( }, } } catch (err) { + const rawMessage = toError(err).message + const projectedMessage = projectToolErrorMessageForCopilot( + rawMessage, + context.resolvedSecretTraceRegistry + ) logger.warn('Failed to write tool output to table', { toolName, outputTable, - error: toError(err).message, + error: projectedMessage, }) span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed) span.addEvent(TraceEvent.CopilotTableError, { - [TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500), + [TraceAttr.ErrorMessage]: projectedMessage.slice(0, 500), }) return { success: false, - error: `Failed to write to table: ${toError(err).message}`, + error: `Failed to write to table: ${rawMessage}`, } } } @@ -281,18 +287,23 @@ export async function maybeWriteReadCsvToTable( }, } } catch (err) { + const rawMessage = toError(err).message + const projectedMessage = projectToolErrorMessageForCopilot( + rawMessage, + context.resolvedSecretTraceRegistry + ) logger.warn('Failed to write read output to table', { toolName, outputTable, - error: toError(err).message, + error: projectedMessage, }) span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed) span.addEvent(TraceEvent.CopilotTableError, { - [TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500), + [TraceAttr.ErrorMessage]: projectedMessage.slice(0, 500), }) return { success: false, - error: `Failed to import into table: ${toError(err).message}`, + error: `Failed to import into table: ${rawMessage}`, } } } diff --git a/apps/sim/lib/copilot/secret-mount-policy.test.ts b/apps/sim/lib/copilot/secret-mount-policy.test.ts new file mode 100644 index 00000000000..49d355cc39d --- /dev/null +++ b/apps/sim/lib/copilot/secret-mount-policy.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + applySecretMountPolicy, + normalizeSecretMountPolicy, +} from '@/lib/copilot/secret-mount-policy' + +describe('normalizeSecretMountPolicy', () => { + it('defaults missing legacy policy data to all and fails malformed scopes closed', () => { + expect(normalizeSecretMountPolicy()).toEqual({ secretScope: 'all', mountedSecrets: [] }) + expect( + normalizeSecretMountPolicy({ secretScope: 'unknown', mountedSecrets: ['SECRET'] }) + ).toEqual({ secretScope: 'selected', mountedSecrets: [] }) + }) + + it('canonicalizes a selected names-only allowlist', () => { + expect( + normalizeSecretMountPolicy({ + secretScope: 'selected', + mountedSecrets: [' B ', 'A', 'B', '', 42], + }) + ).toEqual({ secretScope: 'selected', mountedSecrets: ['B', 'A'] }) + }) + + it('preserves selected with an empty list as no access', () => { + expect(normalizeSecretMountPolicy({ secretScope: 'selected' })).toEqual({ + secretScope: 'selected', + mountedSecrets: [], + }) + }) +}) + +describe('applySecretMountPolicy', () => { + it('allows every explicit reference under the all policy', () => { + expect(applySecretMountPolicy(['B', ' A ', 'B'])).toEqual(['B', 'A']) + }) + + it('returns exact explicit references under a selected policy', () => { + expect( + applySecretMountPolicy(['B'], { + secretScope: 'selected', + mountedSecrets: ['A', 'B'], + }) + ).toEqual(['B']) + }) + + it('fails atomically when selected policy denies any reference', () => { + expect(() => + applySecretMountPolicy(['A', 'B'], { + secretScope: 'selected', + mountedSecrets: ['A'], + }) + ).toThrow('Secret access is not allowed for: B') + }) +}) diff --git a/apps/sim/lib/copilot/secret-mount-policy.ts b/apps/sim/lib/copilot/secret-mount-policy.ts new file mode 100644 index 00000000000..3a7305a8e8b --- /dev/null +++ b/apps/sim/lib/copilot/secret-mount-policy.ts @@ -0,0 +1,75 @@ +export type SecretMountScope = 'all' | 'selected' + +export interface SecretMountPolicy { + secretScope: SecretMountScope + mountedSecrets: string[] +} + +export const MAX_SECRET_MOUNT_NAMES = 100 +export const MAX_SECRET_MOUNT_NAME_LENGTH = 1024 + +export const DEFAULT_SECRET_MOUNT_POLICY: SecretMountPolicy = { + secretScope: 'all', + mountedSecrets: [], +} + +interface SecretMountPolicyInput { + secretScope?: unknown + mountedSecrets?: unknown +} + +function normalizeSecretNames(value: unknown): string[] { + if (!Array.isArray(value)) return [] + + const names = new Set() + for (const candidate of value) { + if (typeof candidate !== 'string') continue + const name = candidate.trim() + if (name) names.add(name) + } + return [...names] +} + +/** + * Normalizes persisted or legacy policy data. A missing scope uses the backwards-compatible + * `all` policy; an explicit invalid scope fails closed. Selected policies keep a canonical, + * de-duplicated names-only allowlist. + */ +export function normalizeSecretMountPolicy( + input?: SecretMountPolicyInput | null +): SecretMountPolicy { + if (input?.secretScope === undefined || input.secretScope === 'all') { + return { ...DEFAULT_SECRET_MOUNT_POLICY } + } + + if (input.secretScope !== 'selected') { + return { secretScope: 'selected', mountedSecrets: [] } + } + + return { + secretScope: 'selected', + mountedSecrets: normalizeSecretNames(input.mountedSecrets), + } +} + +/** + * Applies a normalized headless allowlist to explicitly referenced secret + * names. A selected policy denies the whole request when any reference is not + * listed so code never runs with a surprising partial environment. + */ +export function applySecretMountPolicy( + requestedNames: readonly string[], + input?: SecretMountPolicyInput | null +): string[] { + const policy = normalizeSecretMountPolicy(input) + const requested = normalizeSecretNames(requestedNames) + if (policy.secretScope === 'all') return requested + + const allowed = new Set(policy.mountedSecrets) + const denied = requested.filter((name) => !allowed.has(name)) + if (denied.length > 0) { + throw new Error(`Secret access is not allowed for: ${denied.join(', ')}`) + } + + return requested +} diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 2342f31efbe..30b5d8d4f17 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -2,11 +2,13 @@ * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -const { isKnownTool, isSimExecuted, isClientExecuted } = vi.hoisted(() => ({ +const { getToolEntry, isKnownTool, isSimExecuted, isClientExecuted } = vi.hoisted(() => ({ + getToolEntry: vi.fn(), isKnownTool: vi.fn(), isSimExecuted: vi.fn(), isClientExecuted: vi.fn(), @@ -17,6 +19,7 @@ const { executeAppTool } = vi.hoisted(() => ({ })) vi.mock('./router', () => ({ + getToolEntry, isKnownTool, isSimExecuted, isClientExecuted, @@ -28,10 +31,87 @@ vi.mock('@/tools', () => ({ import { clearHandlers, executeTool, registerHandler } from './executor' +const toolExecutorLogger = vi.mocked(loggerMock.createLogger).mock.results[ + vi.mocked(loggerMock.createLogger).mock.calls.findIndex(([name]) => name === 'ToolExecutor') +]?.value + describe('copilot tool executor fallback', () => { beforeEach(() => { vi.clearAllMocks() clearHandlers() + getToolEntry.mockReturnValue(undefined) + }) + + it('enforces catalog-required permissions before dispatch and fails closed when absent', async () => { + getToolEntry.mockReturnValue({ requiredPermission: 'write' }) + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true }) + registerHandler('function_execute', handler) + + await expect( + executeTool('function_execute', { code: 'return 1' }, { userId: 'user-1', workflowId: '' }) + ).resolves.toEqual({ + success: false, + error: + "Permission denied: function_execute requires write access. You have 'none' permission.", + }) + await expect( + executeTool( + 'function_execute', + { code: 'return 1' }, + { userId: 'user-1', workflowId: '', userPermission: 'read' } + ) + ).resolves.toEqual({ + success: false, + error: + "Permission denied: function_execute requires write access. You have 'read' permission.", + }) + expect(handler).not.toHaveBeenCalled() + }) + + it('dispatches catalog-protected tools when the current permission satisfies the requirement', async () => { + getToolEntry.mockReturnValue({ requiredPermission: 'write' }) + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true, output: 'ok' }) + registerHandler('function_execute', handler) + + await expect( + executeTool( + 'function_execute', + { code: 'return 1' }, + { userId: 'user-1', workflowId: '', userPermission: 'write' } + ) + ).resolves.toEqual({ success: true, output: 'ok' }) + expect(handler).toHaveBeenCalledOnce() + }) + + it('projects resolved secrets before logging registered handler failures', async () => { + const secret = 'mounted-secret-value' + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-secret' }, + ]) + registry.recordResolved('API_KEY', secret) + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + registerHandler('throwing_tool', async () => { + throw new Error(`Provider reflected ${secret}`) + }) + + await expect( + executeTool('throwing_tool', {}, { userId: 'user-1', resolvedSecretTraceRegistry: registry }) + ).resolves.toEqual({ success: false, error: `Provider reflected ${secret}` }) + + expect(toolExecutorLogger?.error).toHaveBeenCalledWith('Tool execution failed', { + toolId: 'throwing_tool', + error: 'Provider reflected {{API_KEY}}', + abortSignalAborted: false, + }) + expect(JSON.stringify(toolExecutorLogger?.error.mock.calls)).not.toContain(secret) }) it('falls back to app tool executor for dynamic sim tools', async () => { diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 3b9efa8438b..6488b695f25 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -1,8 +1,10 @@ import { createLogger } from '@sim/logger' +import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' +import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' import { executeTool as executeAppTool } from '@/tools' -import { isClientExecuted, isKnownTool, isSimExecuted } from './router' +import { getToolEntry, isClientExecuted, isKnownTool, isSimExecuted } from './router' import type { ToolCallDescriptor, ToolExecutionContext, @@ -44,6 +46,20 @@ export async function executeTool( params: Record, context: ToolExecutionContext ): Promise { + const requiredPermission = getToolEntry(toolId)?.requiredPermission + if ( + requiredPermission && + !permissionSatisfies( + (context.userPermission ?? null) as PermissionType | null, + requiredPermission + ) + ) { + return { + success: false, + error: `Permission denied: ${toolId} requires ${requiredPermission} access. You have '${context.userPermission ?? 'none'}' permission.`, + } + } + const normalizedParams = normalizeToolParams(toolId, params, context) // Client-routed tools (e.g. run_workflow) are normally executed in the browser and never @@ -82,7 +98,7 @@ export async function executeTool( const message = toError(error).message logger.error('Tool execution failed', { toolId, - error: message, + error: projectToolErrorMessageForCopilot(message, context.resolvedSecretTraceRegistry), abortSignalAborted: context.abortSignal?.aborted ?? false, }) return { success: false, error: message } diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index a08fda51758..f25e27e24c2 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -1,5 +1,6 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { MothershipResource } from '@/lib/copilot/resources/types' +import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' export interface ToolExecutionContext { @@ -10,6 +11,8 @@ export interface ToolExecutionContext { messageId?: string executionId?: string runId?: string + /** Stable identity of the individual tool call being executed. */ + toolCallId?: string billingAttribution?: BillingAttributionSnapshot copilotToolExecution?: boolean requestMode?: string @@ -24,7 +27,9 @@ export interface ToolExecutionContext { abortSignal?: AbortSignal userTimezone?: string userPermission?: string - decryptedEnvVars?: Record + secretMountPolicy?: SecretMountPolicy + /** Undefined uses the execution actor; null explicitly disables raw secret mounting. */ + secretActorUserId?: string | null resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry } diff --git a/apps/sim/lib/copilot/tools/client/completion.ts b/apps/sim/lib/copilot/tools/client/completion.ts index ef66300b447..b99cb55cf98 100644 --- a/apps/sim/lib/copilot/tools/client/completion.ts +++ b/apps/sim/lib/copilot/tools/client/completion.ts @@ -26,10 +26,12 @@ export async function reportClientToolCompletion( toolCallId: string, status: AsyncConfirmationStatus, message?: string, - data?: AsyncCompletionData + data?: AsyncCompletionData, + executionId?: string ): Promise { const basePayload = { toolCallId, + ...(executionId ? { executionId } : {}), status, message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'), ...(data !== undefined ? { data } : {}), @@ -61,6 +63,7 @@ export async function reportClientToolCompletion( const retryResponse = await send( JSON.stringify({ toolCallId, + ...(executionId ? { executionId } : {}), status, message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'), data: dataWithoutLogs, diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts index ac5fff66d70..873497f3407 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts @@ -102,6 +102,7 @@ import { describe('run tool execution cancellation', () => { beforeEach(() => { vi.clearAllMocks() + window.sessionStorage.clear() getCurrentExecutionId.mockReturnValue(null) getWorkflowEntries.mockReturnValue([]) loadExecutionPointer.mockResolvedValue(null) @@ -133,6 +134,7 @@ describe('run tool execution cancellation', () => { it('can report a manual stop using the explicit toolCallId override', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) + getCurrentExecutionId.mockReturnValueOnce('exec-manual') await reportManualRunToolStop('wf-1', 'tool-override') @@ -143,13 +145,14 @@ describe('run tool execution cancellation', () => { body: expect.stringContaining('"toolCallId":"tool-override"'), }) ) + expect(fetchMock.mock.calls[0][1]?.body).toContain('"executionId":"exec-manual"') }) it('prefers workflow_input, forwards triggerBlockId, and respects useDeployedState', async () => { executeWorkflowWithFullLogging.mockResolvedValueOnce({ success: true, - output: { ok: true }, - logs: [], + output: { token: 'raw-secret-output' }, + logs: [{ output: 'raw-secret-log' }], }) executeRunToolOnClient('tool-2', 'run_workflow', { @@ -172,6 +175,41 @@ describe('run tool execution cancellation', () => { useDraftState: false, }) ) + const executionId = executeWorkflowWithFullLogging.mock.calls[0][0].executionId + await vi.waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + '/api/copilot/confirm', + expect.objectContaining({ + body: expect.stringContaining(`"executionId":"${executionId}"`), + }) + ) + }) + expect(fetch.mock.calls[0][1]?.body).not.toContain('raw-secret') + }) + + it('reports the workflow execution id with terminal error results', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + executeWorkflowWithFullLogging.mockResolvedValueOnce({ + success: false, + output: {}, + error: 'workflow failed', + logs: [], + }) + + executeRunToolOnClient('tool-error', 'run_workflow', { workflowId: 'wf-1' }) + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/copilot/confirm', + expect.objectContaining({ + body: expect.stringContaining('"status":"error"'), + }) + ) + }) + const executionId = executeWorkflowWithFullLogging.mock.calls[0][0].executionId + expect(fetchMock.mock.calls[0][1]?.body).toContain(`"executionId":"${executionId}"`) + expect(fetchMock.mock.calls[0][1]?.body).not.toContain('workflow failed') }) it('treats a tab-local execution pointer as handled in background', async () => { @@ -197,6 +235,33 @@ describe('run tool execution cancellation', () => { body: expect.stringContaining('"status":"background"'), }) ) + expect(fetchMock.mock.calls[0][1]?.body).toContain('"executionId":"exec-existing"') + }) + + it('strips raw payloads from legacy pending completion recovery', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + loadExecutionPointer.mockResolvedValueOnce({ + workflowId: 'wf-1', + executionId: 'exec-existing', + lastEventId: 7, + }) + window.sessionStorage.setItem( + 'sim:copilot:run-tool-completion:tool-recovered', + JSON.stringify({ + status: 'success', + message: 'legacy raw-secret-error', + data: { output: 'legacy raw-secret-output', logs: ['legacy raw-secret-log'] }, + executionId: 'exec-existing', + }) + ) + + await expect(bindRunToolToExecution('tool-recovered', 'wf-1')).resolves.toBe(true) + + const body = fetchMock.mock.calls[0][1]?.body + expect(body).toContain('"status":"success"') + expect(body).toContain('"executionId":"exec-existing"') + expect(body).not.toContain('raw-secret') }) it('does not recover from shared console rows without a tab-local pointer', async () => { @@ -241,6 +306,7 @@ describe('run tool execution cancellation', () => { }) expect(clearExecutionPointer).not.toHaveBeenCalled() expect(setIsExecuting).toHaveBeenCalledWith('wf-1', false) + expect(fetchMock.mock.calls[0][1]?.body).toContain('"executionId":"exec-1"') expect(fetchMock).not.toHaveBeenCalledWith( '/api/copilot/confirm', expect.objectContaining({ diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts index cd66f64031f..62a7bc15110 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts @@ -1,9 +1,9 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isPlainRecord } from '@sim/utils/object' import { ASYNC_TOOL_CONFIRMATION_STATUS, - type AsyncCompletionData, type AsyncConfirmationStatus, } from '@/lib/copilot/async-runs/lifecycle' import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' @@ -17,6 +17,7 @@ import { CompletionReportError, reportClientToolCompletion as reportCompletion, } from '@/lib/copilot/tools/client/completion' +import { getWorkflowToolCompletionMessage } from '@/lib/copilot/tools/workflow-tools' import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils' import { SSEEventHandlerError, SSEStreamInterruptedError } from '@/hooks/use-execution-stream' import { useExecutionStore } from '@/stores/execution/store' @@ -36,8 +37,7 @@ const PENDING_COMPLETION_STORAGE_PREFIX = 'sim:copilot:run-tool-completion:' interface PendingCompletionReport { status: AsyncConfirmationStatus - message?: string - data?: AsyncCompletionData + executionId?: string } function resolveWorkflowInput(params: Record): unknown { @@ -141,8 +141,11 @@ export async function bindRunToolToExecution( await reportCompletion( toolCallId, pendingCompletion.status, - pendingCompletion.message, - pendingCompletion.data + getWorkflowToolCompletionMessage(pendingCompletion.status), + pendingCompletion.status === MothershipStreamV1ToolOutcome.cancelled + ? { reason: 'user_cancelled', cancelledByUser: true } + : undefined, + pendingCompletion.executionId ?? pointer.executionId ) clearPendingCompletionReport(toolCallId) } catch (error) { @@ -160,12 +163,9 @@ export async function bindRunToolToExecution( await reportCompletion( toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.background, - 'Client recovered an existing workflow execution; continuing in background.', - { - workflowId, - executionId: pointer.executionId, - lastEventId: pointer.lastEventId, - } + getWorkflowToolCompletionMessage(ASYNC_TOOL_CONFIRMATION_STATUS.background), + undefined, + pointer.executionId ) } catch (error) { logger.warn('[RunTool] Failed to report recovered execution as background', { @@ -186,8 +186,8 @@ export async function bindRunToolToExecution( * Mirrors staging's RunWorkflowClientTool.handleAccept(): * 1. Execute via executeWorkflowWithFullLogging * 2. Update client tool state directly (success/error) - * 3. Report completion to server via /api/copilot/confirm (Redis), - * where the server-side handler picks it up and tells Go + * 3. Report a structural completion notification; the server restores the + * bound execution result from its log before resuming Copilot */ export function executeRunToolOnClient( toolCallId: string, @@ -246,15 +246,19 @@ export async function reportManualRunToolStop( manuallyStoppedToolCallIds.add(toolCallId) } + const executionId = + useExecutionStore.getState().getCurrentExecutionId(workflowId) ?? + (await loadExecutionPointer(workflowId).catch(() => null))?.executionId + await reportCompletion( toolCallId, MothershipStreamV1ToolOutcome.cancelled, - 'Workflow execution was stopped manually by the user.', + getWorkflowToolCompletionMessage(MothershipStreamV1ToolOutcome.cancelled), { reason: 'user_cancelled', cancelledByUser: true, - workflowId, - } + }, + executionId ) } @@ -347,7 +351,6 @@ async function doExecuteRunTool( const executionId = generateId() setCurrentExecutionId(targetWorkflowId, executionId) saveExecutionPointer({ workflowId: targetWorkflowId, executionId, lastEventId: 0 }) - const executionStartTime = new Date().toISOString() const releaseVisibleExecutionForBackground = () => { const { setCurrentExecutionId: clearExecId, setActiveBlocks } = useExecutionStore.getState() if (activeRunToolByWorkflowId.get(targetWorkflowId) === toolCallId) { @@ -360,12 +363,15 @@ async function doExecuteRunTool( const onPageHide = () => { if (manuallyStoppedToolCallIds.has(toolCallId)) return + const activeExecutionId = + useExecutionStore.getState().getCurrentExecutionId(targetWorkflowId) ?? executionId navigator.sendBeacon( COPILOT_CONFIRM_API_PATH, new Blob( [ JSON.stringify({ toolCallId, + executionId: activeExecutionId, status: 'background', message: 'Client disconnected, execution continuing server-side', }), @@ -397,6 +403,7 @@ async function doExecuteRunTool( workflowId: targetWorkflowId, workflowInput, executionId, + copilotToolCallId: toolCallId, overrideTriggerType: 'copilot', triggerBlockId, useDraftState, @@ -406,28 +413,16 @@ async function doExecuteRunTool( preserveExecutionOnTerminal: true, }) + const completedExecutionId = + useExecutionStore.getState().getCurrentExecutionId(targetWorkflowId) ?? executionId + // Determine success (same logic as staging's RunWorkflowClientTool) - let succeeded = true - let errorMessage: string | undefined - try { - if (result && typeof result === 'object' && 'success' in (result as any)) { - succeeded = Boolean((result as any).success) - if (!succeeded) { - errorMessage = (result as any)?.error || (result as any)?.output?.error - } - } else if ( - result && - typeof result === 'object' && - 'execution' in (result as any) && - (result as any).execution - ) { - succeeded = Boolean((result as any).execution.success) - if (!succeeded) { - errorMessage = - (result as any).execution?.error || (result as any).execution?.output?.error - } - } - } catch {} + const succeeded = + isPlainRecord(result) && Object.hasOwn(result, 'success') + ? Boolean(result.success) + : isPlainRecord(result) && isPlainRecord(result.execution) + ? Boolean(result.execution.success) + : true if (manuallyStoppedToolCallIds.has(toolCallId)) { logger.info('[RunTool] Skipping generic completion — already manually stopped', { @@ -438,31 +433,30 @@ async function doExecuteRunTool( logger.info('[RunTool] Workflow execution succeeded', { toolCallId, toolName }) const pendingCompletion = { status: MothershipStreamV1ToolOutcome.success, - message: `Workflow execution completed. Started at: ${executionStartTime}`, - data: buildResultData(result), + executionId: completedExecutionId, } savePendingCompletionReport(toolCallId, pendingCompletion) await reportCompletion( toolCallId, pendingCompletion.status, - pendingCompletion.message, - pendingCompletion.data + getWorkflowToolCompletionMessage(pendingCompletion.status), + undefined, + pendingCompletion.executionId ) clearPendingCompletionReport(toolCallId) } else { - const msg = errorMessage || 'Workflow execution failed' - logger.error('[RunTool] Workflow execution failed', { toolCallId, toolName, error: msg }) + logger.error('[RunTool] Workflow execution failed', { toolCallId, toolName }) const pendingCompletion = { status: MothershipStreamV1ToolOutcome.error, - message: msg, - data: buildResultData(result), + executionId: completedExecutionId, } savePendingCompletionReport(toolCallId, pendingCompletion) await reportCompletion( toolCallId, pendingCompletion.status, - pendingCompletion.message, - pendingCompletion.data + getWorkflowToolCompletionMessage(pendingCompletion.status), + undefined, + pendingCompletion.executionId ) clearPendingCompletionReport(toolCallId) } @@ -489,7 +483,9 @@ async function doExecuteRunTool( await reportCompletion( toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.background, - 'Client lost local stream processing; workflow execution may still be continuing server-side.' + getWorkflowToolCompletionMessage(ASYNC_TOOL_CONFIRMATION_STATUS.background), + undefined, + err.executionId ?? executionId ) return } @@ -504,7 +500,15 @@ async function doExecuteRunTool( return } logger.error('[RunTool] Workflow execution threw', { toolCallId, toolName, error: msg }) - await reportCompletion(toolCallId, MothershipStreamV1ToolOutcome.error, msg) + const failedExecutionId = + useExecutionStore.getState().getCurrentExecutionId(targetWorkflowId) ?? executionId + await reportCompletion( + toolCallId, + MothershipStreamV1ToolOutcome.error, + getWorkflowToolCompletionMessage(MothershipStreamV1ToolOutcome.error), + undefined, + failedExecutionId + ) } } finally { if (typeof window !== 'undefined') { @@ -529,34 +533,3 @@ async function doExecuteRunTool( } } } - -/** - * Extract a structured result payload from the raw execution result - * for the LLM to see the actual workflow output. - */ -function buildResultData(result: unknown): Record | undefined { - if (!result || typeof result !== 'object') return undefined - - const r = result as Record - - if ('success' in r) { - return { - success: r.success, - output: r.output, - logs: r.logs, - error: r.error, - } - } - - if ('execution' in r && r.execution && typeof r.execution === 'object') { - const exec = r.execution as Record - return { - success: exec.success, - output: exec.output, - logs: exec.logs, - error: exec.error, - } - } - - return undefined -} diff --git a/apps/sim/lib/copilot/tools/handlers/context.ts b/apps/sim/lib/copilot/tools/handlers/context.ts index 02ba4d078be..06f1c05716c 100644 --- a/apps/sim/lib/copilot/tools/handlers/context.ts +++ b/apps/sim/lib/copilot/tools/handlers/context.ts @@ -3,8 +3,11 @@ import { type BillingAttributionSnapshot, resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { + type CopilotEnvironmentContext, + prepareCopilotEnvironmentContext, +} from '@/lib/copilot/environment-context' import type { ExecutionContext } from '@/lib/copilot/request/types' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { getWorkflowById } from '@/lib/workflows/utils' export async function prepareExecutionContext( @@ -13,14 +16,14 @@ export async function prepareExecutionContext( chatId?: string, options?: { workspaceId?: string - decryptedEnvVars?: Record + environmentContext?: CopilotEnvironmentContext billingAttribution?: BillingAttributionSnapshot } ): Promise { const workspaceId = options?.workspaceId ?? (await getWorkflowById(workflowId))?.workspaceId ?? undefined - const [decryptedEnvVars, billingAttribution] = await Promise.all([ - options?.decryptedEnvVars ?? getEffectiveDecryptedEnv(userId, workspaceId), + const [environmentContext, billingAttribution] = await Promise.all([ + options?.environmentContext ?? prepareCopilotEnvironmentContext(userId, workspaceId), options?.billingAttribution ? Promise.resolve(assertBillingAttributionSnapshot(options.billingAttribution)) : workspaceId @@ -39,7 +42,7 @@ export async function prepareExecutionContext( workflowId, workspaceId, chatId, - decryptedEnvVars, + ...environmentContext, billingAttribution, } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts new file mode 100644 index 00000000000..3fb9d61ea97 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getCopilotDeploymentIdempotencyKey, + getHistoricalDeploymentAttemptError, +} from '@/lib/copilot/tools/handlers/deployment/context' + +describe('getCopilotDeploymentIdempotencyKey', () => { + it('is stable for a replay of the same logical tool call', () => { + const context = { executionId: 'execution-1', runId: 'run-1', toolCallId: 'call-1' } + + expect(getCopilotDeploymentIdempotencyKey(context)).toBe( + getCopilotDeploymentIdempotencyKey(context) + ) + }) + + it('separates different tool calls within the same Mothership execution', () => { + expect( + getCopilotDeploymentIdempotencyKey({ executionId: 'execution-1', toolCallId: 'call-1' }) + ).not.toBe( + getCopilotDeploymentIdempotencyKey({ executionId: 'execution-1', toolCallId: 'call-2' }) + ) + }) + + it('does not derive a turn-wide key when the tool-call identity is unavailable', () => { + expect(getCopilotDeploymentIdempotencyKey({ executionId: 'execution-1' })).toBeUndefined() + }) +}) + +describe('getHistoricalDeploymentAttemptError', () => { + it('requires a new tool call when the persisted attempt is no longer current', () => { + expect(getHistoricalDeploymentAttemptError({ isCurrent: false }, 'redeploy')).toContain( + 'Start a new tool call' + ) + expect(getHistoricalDeploymentAttemptError({ isCurrent: true }, 'redeploy')).toBeNull() + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/context.ts b/apps/sim/lib/copilot/tools/handlers/deployment/context.ts new file mode 100644 index 00000000000..c4affb106a6 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/context.ts @@ -0,0 +1,35 @@ +import type { ToolExecutionContext } from '@/lib/copilot/tool-executor/types' + +type DeploymentToolContext = Pick< + ToolExecutionContext, + 'executionId' | 'messageId' | 'runId' | 'toolCallId' +> + +interface DeploymentAttemptCurrentState { + isCurrent?: boolean +} + +/** + * Builds a replay-stable idempotency key for one logical Copilot tool call. + * The orchestration layer generates a fresh key when legacy callers do not + * provide a tool-call identity. + */ +export function getCopilotDeploymentIdempotencyKey( + context: DeploymentToolContext +): string | undefined { + if (!context.toolCallId) return undefined + + const executionScope = context.executionId ?? context.runId ?? context.messageId + return executionScope + ? `copilot:${executionScope}:tool-call:${context.toolCallId}` + : `copilot:tool-call:${context.toolCallId}` +} + +/** Rejects a replay whose persisted operation no longer describes production. */ +export function getHistoricalDeploymentAttemptError( + attempt: DeploymentAttemptCurrentState | null | undefined, + action: string +): string | null { + if (attempt?.isCurrent !== false) return null + return `The ${action} operation associated with this tool call is historical and no longer describes production. Start a new tool call to create a new logical deployment operation.` +} diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts new file mode 100644 index 00000000000..e0595796572 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts @@ -0,0 +1,237 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckChatAccess, + mockEnsureWorkflowAccess, + mockPerformChatUndeploy, + mockPerformDeleteWorkflowMcpTool, + mockPerformFullDeploy, + mockPerformFullUndeploy, +} = vi.hoisted(() => ({ + mockCheckChatAccess: vi.fn(), + mockEnsureWorkflowAccess: vi.fn(), + mockPerformChatUndeploy: vi.fn(), + mockPerformDeleteWorkflowMcpTool: vi.fn(), + mockPerformFullDeploy: vi.fn(), + mockPerformFullUndeploy: vi.fn(), +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performChatDeploy: vi.fn(), + performChatUndeploy: mockPerformChatUndeploy, + performFullDeploy: mockPerformFullDeploy, + performFullUndeploy: mockPerformFullUndeploy, +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateWorkflowMcpTool: vi.fn(), + performDeleteWorkflowMcpTool: mockPerformDeleteWorkflowMcpTool, + performUpdateWorkflowMcpTool: vi.fn(), +})) + +vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ + getDeployedWorkflowInputFormat: vi.fn(), +})) + +vi.mock('@/lib/mcp/workflow-tool-schema', () => ({ + applyDescriptionOverrides: vi.fn(), + generateToolInputSchema: vi.fn(), + sanitizeToolName: vi.fn(), +})) + +vi.mock('@/app/api/chat/utils', () => ({ + checkChatAccess: mockCheckChatAccess, + checkWorkflowAccessForChatCreation: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + ChatDeployAuthNotAllowedError: class ChatDeployAuthNotAllowedError extends Error {}, + validateChatDeployAuth: vi.fn(), +})) + +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkflowAccess: mockEnsureWorkflowAccess, +})) + +import { + executeDeployApi, + executeDeployChat, + executeDeployMcp, + executeRedeploy, +} from '@/lib/copilot/tools/handlers/deployment/deploy' + +describe('deployment handlers', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockEnsureWorkflowAccess.mockResolvedValue({ + workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + }) + }) + + it('undeploys the API without approval context when permission gating is disabled', async () => { + mockPerformFullUndeploy.mockResolvedValue({ success: true }) + + const result = await executeDeployApi( + { workflowId: 'workflow-1', action: 'undeploy' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + toolCallId: 'call-1', + } + ) + + expect(result.success).toBe(true) + expect(mockPerformFullUndeploy).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + userId: 'user-1', + }) + }) + + it('uses the tool-call identity for deployment idempotency', async () => { + mockPerformFullDeploy.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { status: 'preparing' }, + }) + + await executeDeployApi( + { + workflowId: 'workflow-1', + action: 'deploy', + versionName: 'Safe deploy', + versionDescription: 'Deploy the latest workflow changes', + }, + { + userId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + toolCallId: 'call-1', + } + ) + + expect(mockPerformFullDeploy).toHaveBeenCalledWith( + expect.objectContaining({ + idempotencyKey: 'copilot:execution-1:tool-call:call-1', + }) + ) + }) + + it('rejects a replay whose active deployment attempt became historical', async () => { + mockPerformFullDeploy.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { status: 'active', isCurrent: false }, + }) + + const result = await executeDeployApi( + { + workflowId: 'workflow-1', + action: 'deploy', + versionName: 'Safe deploy', + versionDescription: 'Deploy the latest workflow changes', + }, + { + userId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + toolCallId: 'call-1', + } + ) + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('historical'), + }) + }) + + it('does not report a historical active attempt as a successful redeploy', async () => { + mockPerformFullDeploy.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { status: 'active', isCurrent: false }, + }) + + const result = await executeRedeploy( + { + workflowId: 'workflow-1', + versionName: 'Safe redeploy', + versionDescription: 'Redeploy the latest workflow changes', + }, + { + userId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + toolCallId: 'call-1', + } + ) + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('historical'), + }) + }) + + it('undeploys chat without approval context when permission gating is disabled', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'chat-1', + identifier: 'production-helper', + title: 'Production Helper', + description: null, + authType: 'public', + allowedEmails: [], + outputConfigs: [], + includeThinking: false, + includeToolCalls: false, + customizations: null, + }, + ]) + mockCheckChatAccess.mockResolvedValue({ hasAccess: true, workspaceId: 'workspace-1' }) + mockPerformChatUndeploy.mockResolvedValue({ success: true }) + + const result = await executeDeployChat( + { workflowId: 'workflow-1', action: 'undeploy' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + toolCallId: 'call-1', + } + ) + + expect(result.success).toBe(true) + expect(mockPerformChatUndeploy).toHaveBeenCalledWith({ + chatId: 'chat-1', + userId: 'user-1', + workspaceId: 'workspace-1', + }) + }) + + it('undeploys MCP without approval context when permission gating is disabled', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ id: 'server-1', name: 'Production MCP' }]) + .mockResolvedValueOnce([{ id: 'tool-1' }]) + mockPerformDeleteWorkflowMcpTool.mockResolvedValue({ success: true }) + + const result = await executeDeployMcp( + { workflowId: 'workflow-1', serverId: 'server-1', action: 'undeploy' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + toolCallId: 'call-1', + } + ) + + expect(result.success).toBe(true) + expect(mockPerformDeleteWorkflowMcpTool).toHaveBeenCalledWith({ + serverId: 'server-1', + toolId: 'tool-1', + workspaceId: 'workspace-1', + userId: 'user-1', + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index f0d14b9ba29..a1db42f3e4c 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -28,6 +28,7 @@ import { } from '@/ee/access-control/utils/permission-check' import { ensureWorkflowAccess } from '../access' import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types' +import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string { return `${baseUrl}/api/workflows/${workflowId}/execute` @@ -190,10 +191,16 @@ export async function executeDeployApi( userId: context.userId, versionDescription, versionName, + idempotencyKey: getCopilotDeploymentIdempotencyKey(context), }) if (!result.success) { return { success: false, error: result.error || 'Failed to deploy workflow' } } + const historicalAttemptError = getHistoricalDeploymentAttemptError( + result.latestDeploymentAttempt, + 'deploy' + ) + if (historicalAttemptError) return { success: false, error: historicalAttemptError } const baseUrl = getBaseUrl() const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) @@ -451,6 +458,7 @@ export async function executeDeployChat( includeThinking: resolvedIncludeThinking, includeToolCalls: resolvedIncludeToolCalls, workspaceId: workflowRecord.workspaceId, + idempotencyKey: getCopilotDeploymentIdempotencyKey(context), }) if (!result.success) { @@ -827,10 +835,16 @@ export async function executeRedeploy( userId: context.userId, versionDescription, versionName, + idempotencyKey: getCopilotDeploymentIdempotencyKey(context), }) if (!result.success) { return { success: false, error: result.error || 'Failed to redeploy workflow' } } + const historicalAttemptError = getHistoricalDeploymentAttemptError( + result.latestDeploymentAttempt, + 'redeploy' + ) + if (historicalAttemptError) return { success: false, error: historicalAttemptError } const baseUrl = getBaseUrl() const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts index eb2cc11467b..fd8ba3c06d2 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts @@ -169,12 +169,18 @@ describe('executePromoteToLive', () => { performActivateVersionMock.mockResolvedValue({ success: true, deployedAt: new Date('2026-05-30T00:00:00.000Z'), + activeDeployment: { + deploymentVersionId: 'dv-3', + version: 3, + deployedAt: '2026-05-30T00:00:00.000Z', + }, latestDeploymentAttempt: { id: 'op-1', deploymentVersionId: 'dv-3', version: 3, action: 'activate', status: 'active', + isCurrent: true, readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, requestedAt: '2026-05-30T00:00:00.000Z', activatedAt: '2026-05-30T00:00:00.000Z', @@ -185,6 +191,8 @@ describe('executePromoteToLive', () => { const result = await executePromoteToLive({ workflowId: 'wf-1', version: 3 }, { userId: 'user-1', workflowId: 'wf-1', + executionId: 'execution-1', + toolCallId: 'call-1', } as ExecutionContext) expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') @@ -192,6 +200,7 @@ describe('executePromoteToLive', () => { workflowId: 'wf-1', version: 3, userId: 'user-1', + idempotencyKey: 'copilot:execution-1:tool-call:call-1', }) expect(result.success).toBe(true) expect(result.output).toMatchObject({ @@ -203,6 +212,37 @@ describe('executePromoteToLive', () => { }) }) + it('does not report a historical active operation as a successful promotion', async () => { + performActivateVersionMock.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { + id: 'op-old', + deploymentVersionId: 'dv-3', + version: 3, + action: 'activate', + status: 'active', + isCurrent: false, + readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, + requestedAt: '2026-05-30T00:00:00.000Z', + activatedAt: '2026-05-30T00:00:00.000Z', + error: null, + }, + }) + + const result = await executePromoteToLive({ workflowId: 'wf-1', version: 3 }, { + userId: 'user-1', + workflowId: 'wf-1', + executionId: 'execution-1', + toolCallId: 'call-1', + } as ExecutionContext) + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('historical'), + }) + }) + it('rejects a non-numeric version like "live"', async () => { const result = await executePromoteToLive({ workflowId: 'wf-1', version: 'live' as never }, { userId: 'user-1', @@ -376,4 +416,44 @@ describe('executeCheckDeploymentStatus', () => { }, }) }) + + it('separates a historical active attempt from the current undeployed state', async () => { + getWorkflowDeploymentSummaryMock.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: { + id: 'op-historical', + deploymentVersionId: 'dv-old', + version: 1, + action: 'deploy', + status: 'active', + isCurrent: false, + readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, + requestedAt: '2026-05-28T00:00:00.000Z', + activatedAt: '2026-05-28T00:00:00.000Z', + error: null, + }, + warnings: ['The latest successful deployment attempt is historical.'], + }) + queueTableRows(schemaMock.workflow, [{ deployedAt: null }]) + + const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { + userId: 'user-1', + workflowId: 'wf-1', + } as ExecutionContext) + + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + isDeployed: false, + api: { + isDeployed: false, + activeDeployment: null, + latestDeploymentAttempt: { + status: 'active', + isCurrent: false, + }, + currentDeploymentAttempt: null, + warnings: [expect.stringContaining('historical')], + }, + }) + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index ff61d1762d1..4224a0d0ce5 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -32,6 +32,7 @@ import type { UpdateDeploymentVersionParams, UpdateWorkspaceMcpServerParams, } from '../param-types' +import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' import { resolveWorkflowStateRef } from './state-refs' export async function executeCheckDeploymentStatus( @@ -79,6 +80,9 @@ export async function executeCheckDeploymentStatus( */ const isApiDeployed = deploymentSummary.activeDeployment !== null const needsRedeployment = isApiDeployed ? await checkNeedsRedeployment(workflowId) : false + const currentDeploymentAttempt = deploymentSummary.latestDeploymentAttempt?.isCurrent + ? deploymentSummary.latestDeploymentAttempt + : null const apiDetails = { isDeployed: isApiDeployed, deployedAt: apiDeploy[0]?.deployedAt || null, @@ -87,6 +91,7 @@ export async function executeCheckDeploymentStatus( needsRedeployment, activeDeployment: deploymentSummary.activeDeployment, latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, + currentDeploymentAttempt, warnings: deploymentSummary.warnings ?? [], } @@ -557,13 +562,19 @@ export async function executePromoteToLive( workflowId, version, userId: context.userId, + idempotencyKey: getCopilotDeploymentIdempotencyKey(context), }) if (!result.success) { return { success: false, error: result.error || 'Failed to promote version' } } + const historicalAttemptError = getHistoricalDeploymentAttemptError( + result.latestDeploymentAttempt, + 'promotion' + ) + if (historicalAttemptError) return { success: false, error: historicalAttemptError } - const isActive = result.latestDeploymentAttempt?.status === 'active' + const isActive = result.activeDeployment?.version === version return { success: true, output: { diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index 0320fb41f35..cef1cf7e4a9 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -1,6 +1,8 @@ /** * @vitest-environment node */ + +import { encryptionMock, encryptionMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' @@ -20,6 +22,7 @@ const { mockFetchServableWorkspaceFileBuffer, mockGetSandboxWorkspaceFilePath, mockListWorkspaceFileFolders, + mockMaterializeCopilotCodeSecrets, } = vi.hoisted(() => ({ mockIsFeatureEnabled: vi.fn(), mockGetTableById: vi.fn(), @@ -36,9 +39,11 @@ const { mockFetchServableWorkspaceFileBuffer: vi.fn(), mockGetSandboxWorkspaceFilePath: vi.fn(), mockListWorkspaceFileFolders: vi.fn(), + mockMaterializeCopilotCodeSecrets: vi.fn(), })) vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled })) +vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/table/service', () => ({ getTableById: mockGetTableById, listTables: mockListTables, @@ -68,8 +73,14 @@ vi.mock('@/lib/copilot/vfs/path-utils', () => ({ decodeVfsPathSegments: (p: string) => p.split('/'), encodeVfsPathSegments: (s: string[]) => s.join('/'), })) +vi.mock('@/lib/copilot/tools/secret-mount-materializer.server', () => ({ + CopilotCodeSecretAccessError: class CopilotCodeSecretAccessError extends Error {}, + materializeCopilotCodeSecrets: mockMaterializeCopilotCodeSecrets, +})) +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const table = { id: 'tbl_1', @@ -93,25 +104,251 @@ describe('executeFunctionExecute trace-secret provenance', () => { beforeEach(() => { vi.clearAllMocks() mockExecuteTool.mockResolvedValue({ success: true }) + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ envVars: {}, catalogEntries: [] }) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' }) }) - it('forwards the registry only through server execution options', async () => { - const resolvedSecretTraceRegistry = { recordResolved: vi.fn() } - - await executeFunctionExecute({ code: 'return {{API_KEY}}' }, { - userId: 'u1', - workspaceId: 'ws_1', - resolvedSecretTraceRegistry, - } as never) + it('mounts only explicit references and imports active provenance out of band', async () => { + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { + options.resolvedSecretTraceRegistry.recordResolved('API_KEY', 'secret-value') + return { success: true, output: { result: 'secret-value' } } + }) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + const runtimeResult = await executeFunctionExecute( + { + code: 'return {{API_KEY}}', + envVars: { ATTACKER_KEY: 'attacker-value' }, + secretScope: 'all', + mountedSecrets: ['ATTACKER_KEY'], + _context: { resolvedSecretTraceRegistry: 'attacker-value' }, + }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) expect(mockExecuteTool).toHaveBeenCalledWith( 'function_execute', - expect.not.objectContaining({ resolvedSecretTraceRegistry: expect.anything() }), - { resolvedSecretTraceRegistry } + expect.objectContaining({ + envVars: { API_KEY: 'secret-value' }, + secretScope: 'selected', + mountedSecrets: ['API_KEY'], + _context: expect.not.objectContaining({ resolvedSecretTraceRegistry: expect.anything() }), + }), + { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) } ) const appParams = mockExecuteTool.mock.calls[0]?.[1] as Record - expect(appParams._context).not.toHaveProperty('resolvedSecretTraceRegistry') expect(JSON.stringify(appParams)).not.toContain('resolvedSecretTraceRegistry') + expect(runtimeResult).toEqual({ success: true, output: { result: 'secret-value' } }) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ + { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, + ]) + }) + + it('does not mount direct environment-map or shell-variable access', async () => { + await executeFunctionExecute( + { code: 'return environmentVariables.API_KEY + "$API_KEY"' }, + { userId: 'u1', workflowId: '', workspaceId: 'ws_1' } + ) + + expect(mockMaterializeCopilotCodeSecrets).not.toHaveBeenCalled() + expect(mockExecuteTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ envVars: {}, secretScope: 'selected', mountedSecrets: [] }), + { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) } + ) + }) + + it('returns the raw runtime result when provenance import fails', async () => { + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + const runtimeResult = { success: true, output: { result: 'secret-value' } } + mockExecuteTool.mockResolvedValue(runtimeResult) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([], { + userId: 'u1', + workspaceId: 'ws_1', + }) + vi.spyOn(resolvedSecretTraceRegistry, 'importProvenance').mockRejectedValueOnce( + new Error('provenance import failed') + ) + + await expect( + executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + ).resolves.toBe(runtimeResult) + expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) + }) + + it('fails parallel projections closed until exact mounted provenance is active', async () => { + let completeMaterialization: ((value: unknown) => void) | undefined + mockMaterializeCopilotCodeSecrets.mockReturnValueOnce( + new Promise((resolve) => { + completeMaterialization = resolve + }) + ) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { + expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) + options.resolvedSecretTraceRegistry.recordResolved('API_KEY', 'secret-value') + return { success: true, output: { result: 'secret-value' } } + }) + + const execution = executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot( + { success: true, output: { result: 'secret-value' } }, + resolvedSecretTraceRegistry + ) + ).toEqual({ success: true }) + + completeMaterialization?.({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + await execution + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ + { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, + ]) + expect(mockExecuteTool).toHaveBeenCalledOnce() + }) + + it('does not activate a mounted reference when the Function route rejects before resolution', async () => { + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + mockExecuteTool.mockResolvedValueOnce({ + success: false, + error: 'Too many sandbox output files requested', + }) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + + await expect( + executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + ).resolves.toEqual({ + success: false, + error: 'Too many sandbox output files requested', + }) + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([]) + }) + + it('releases pending provenance without activation when mounting is denied', async () => { + mockMaterializeCopilotCodeSecrets.mockRejectedValueOnce(new Error('mount denied')) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + + await expect( + executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + ).rejects.toThrow('mount denied') + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([]) + expect(mockExecuteTool).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index e064b40d579..45634e9792d 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,4 +1,11 @@ import { createLogger } from '@sim/logger' +import { applySecretMountPolicy } from '@/lib/copilot/secret-mount-policy' +import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { + CopilotCodeSecretAccessError, + type MaterializedCopilotCodeSecrets, + materializeCopilotCodeSecrets, +} from '@/lib/copilot/tools/secret-mount-materializer.server' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' @@ -23,8 +30,9 @@ import { hasCloudStorage, } from '@/lib/uploads/core/storage-service' import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils' +import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { executeTool as executeAppTool } from '@/tools' -import type { ToolExecutionContext, ToolExecutionResult } from '../../tool-executor/types' const logger = createLogger('CopilotFunctionExecute') @@ -451,67 +459,112 @@ export async function resolveInputFiles( return sandboxFiles } +async function importMountedProvenance( + source: ResolvedSecretTraceRegistry, + target: ResolvedSecretTraceRegistry | undefined +): Promise { + if (!target) return + + try { + const imported = await target.importProvenance(source.exportProvenance(), { trusted: true }) + if (!imported) target.markIncomplete() + } catch { + target.markIncomplete() + } +} + export async function executeFunctionExecute( params: Record, context: ToolExecutionContext ): Promise { const enrichedParams = { ...params } - - if (context.decryptedEnvVars && Object.keys(context.decryptedEnvVars).length > 0) { - enrichedParams.envVars = { - ...context.decryptedEnvVars, - ...((enrichedParams.envVars as Record) || {}), + const requestedNames = applySecretMountPolicy( + extractCodeSecretNames(params.code, params.language), + context.secretMountPolicy + ) + const completePendingActivation = + requestedNames.length > 0 + ? context.resolvedSecretTraceRegistry?.beginPendingActivation() + : undefined + let mountedRegistry: ResolvedSecretTraceRegistry | undefined + + try { + const secretActorUserId = + context.secretActorUserId === undefined ? context.userId : context.secretActorUserId + let mounted: MaterializedCopilotCodeSecrets = { envVars: {}, catalogEntries: [] } + if (requestedNames.length > 0) { + if (!secretActorUserId) { + throw new CopilotCodeSecretAccessError('Secret access is unavailable for this Copilot run') + } + if (!context.workspaceId) { + throw new CopilotCodeSecretAccessError( + 'A workspace is required to mount secrets into Copilot code' + ) + } + mounted = await materializeCopilotCodeSecrets({ + actorUserId: secretActorUserId, + workspaceId: context.workspaceId, + requestedNames, + }) } - } + mountedRegistry = new ResolvedSecretTraceRegistry(mounted.catalogEntries, { + userId: secretActorUserId ?? context.userId, + ...(context.workspaceId ? { workspaceId: context.workspaceId } : {}), + }) - if (context.workspaceId) { - const inputs = enrichedParams.inputs as - | { - files?: CanonicalFileInput[] - directories?: CanonicalDirectoryInput[] - tables?: CanonicalTableInput[] + enrichedParams.envVars = mounted.envVars + enrichedParams.secretScope = 'selected' + enrichedParams.mountedSecrets = requestedNames + + if (context.workspaceId) { + const inputs = enrichedParams.inputs as + | { + files?: CanonicalFileInput[] + directories?: CanonicalDirectoryInput[] + tables?: CanonicalTableInput[] + } + | undefined + const inputFiles = [ + ...((enrichedParams.inputFiles as unknown[] | undefined) ?? []), + ...(inputs?.files ?? []), + ] + const inputDirectories = inputs?.directories ?? [] + const inputTables = [ + ...((enrichedParams.inputTables as unknown[] | undefined) ?? []), + ...(inputs?.tables ?? []), + ] + + if (inputFiles?.length || inputTables?.length || inputDirectories.length) { + const resolved = await resolveInputFiles( + context.workspaceId, + inputFiles, + inputTables, + inputDirectories + ) + if (resolved.length > 0) { + const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] + enrichedParams._sandboxFiles = [...existing, ...resolved] } - | undefined - const inputFiles = [ - ...((enrichedParams.inputFiles as unknown[] | undefined) ?? []), - ...(inputs?.files ?? []), - ] - const inputDirectories = inputs?.directories ?? [] - const inputTables = [ - ...((enrichedParams.inputTables as unknown[] | undefined) ?? []), - ...(inputs?.tables ?? []), - ] - - if (inputFiles?.length || inputTables?.length || inputDirectories.length) { - const resolved = await resolveInputFiles( - context.workspaceId, - inputFiles, - inputTables, - inputDirectories - ) - if (resolved.length > 0) { - const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] - enrichedParams._sandboxFiles = [...existing, ...resolved] } } - } - enrichedParams._context = { - ...(typeof enrichedParams._context === 'object' && enrichedParams._context !== null - ? (enrichedParams._context as object) - : {}), - userId: context.userId, - workflowId: context.workflowId, - workspaceId: context.workspaceId, - chatId: context.chatId, - executionId: context.executionId, - runId: context.runId, - enforceCredentialAccess: true, - } + enrichedParams._context = { + userId: context.userId, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + chatId: context.chatId, + executionId: context.executionId, + runId: context.runId, + enforceCredentialAccess: true, + } - return context.resolvedSecretTraceRegistry - ? executeAppTool('function_execute', enrichedParams, { - resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, - }) - : executeAppTool('function_execute', enrichedParams) + return await executeAppTool('function_execute', enrichedParams, { + resolvedSecretTraceRegistry: mountedRegistry, + }) + } finally { + if (mountedRegistry) { + await importMountedProvenance(mountedRegistry, context.resolvedSecretTraceRegistry) + } + completePendingActivation?.() + } } diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index 097794dac06..b7d1559d44d 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -138,6 +138,7 @@ vi.mock('../access', () => ({ getDefaultWorkspaceId: vi.fn(), })) +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { applyCreateWorkflowOutputToContext } from '@/lib/copilot/request/tools/workflow-context' import { performUpdateWorkflow } from '@/lib/workflows/orchestration' import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' @@ -716,6 +717,61 @@ describe('Copilot workflow execution billing attribution', () => { expect(JSON.stringify(result)).not.toContain('encrypted-secret') }) + it('fails concurrent tool-result projection closed until child provenance is imported', async () => { + const registry = new ResolvedSecretTraceRegistry([], { + userId: 'user-1', + workspaceId: 'workspace-1', + }) + const context: ExecutionContext = { + ...executionContext, + resolvedSecretTraceRegistry: registry, + } + let resolveExecution!: (value: unknown) => void + let markExecutionStarted!: () => void + const executionStarted = new Promise((resolve) => { + markExecutionStarted = resolve + }) + executeWorkflowMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveExecution = resolve + markExecutionStarted() + }) + ) + + const execution = executeRunWorkflow( + { workflowId: 'workflow-1', useMockPayload: true }, + context + ) + await executionStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { value: 'secret-value' } }, registry) + ).not.toHaveProperty('output') + + resolveExecution({ + success: true, + output: { value: 'secret-value' }, + logs: [], + metadata: { executionId: 'new-execution-1' }, + executionState: { + resolvedSecretTraceProvenance: { + version: 1, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, + }) + + await expect(execution).resolves.toMatchObject({ success: true }) + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { value: 'secret-value' } }, registry) + ).toMatchObject({ output: { value: '{{API_KEY}}' } }) + }) + it('marks provenance incomplete when child execution returns no trusted state', async () => { const registry = new ResolvedSecretTraceRegistry() const context: ExecutionContext = { diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 4a8823cd717..260bf73fbd2 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -95,10 +95,12 @@ async function executeCopilotWorkflowTarget(params: { params.workflow.workspaceId, childExecutionId ) + const trustedInitialResolvedSecretTraceProvenance = + params.context.resolvedSecretTraceRegistry?.exportProvenanceForValue(params.input) + const completePendingActivation = + params.context.resolvedSecretTraceRegistry?.beginPendingActivation() try { - const trustedInitialResolvedSecretTraceProvenance = - params.context.resolvedSecretTraceRegistry?.exportProvenanceForValue(params.input) const result = await executeWorkflow( params.workflow, generateRequestId(), @@ -139,6 +141,8 @@ async function executeCopilotWorkflowTarget(params: { await releaseExecutionSlot(childExecutionId) } throw error + } finally { + completePendingActivation?.() } } diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts new file mode 100644 index 00000000000..14313766c5d --- /dev/null +++ b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts @@ -0,0 +1,456 @@ +/** + * @vitest-environment node + */ +import { credential, environment, workspaceEnvironment } from '@sim/db/schema' +import { + dbChainMockFns, + encryptionMock, + encryptionMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { or } from 'drizzle-orm' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckWorkspaceAccess } = vi.hoisted(() => ({ + mockCheckWorkspaceAccess: vi.fn(), +})) + +vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +import { + CopilotCodeSecretAccessError, + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, + materializeCopilotCodeSecrets, +} from '@/lib/copilot/tools/secret-mount-materializer.server' + +interface CredentialRow { + type: 'env_personal' | 'env_workspace' + envKey: string + envOwnerUserId: string | null + role: 'admin' | 'member' | null + status: 'active' | 'pending' | 'revoked' | null + updatedAt: Date + encryptedValue: string | null + encryptedValueBytes: number | null +} + +function queueSources(input: { + personal?: Record + personalOverLimit?: string[] + workspace?: Record + workspaceOverLimit?: string[] + credentials?: CredentialRow[] +}): void { + queueTableRows(environment, [ + { variables: input.personal ?? {}, overLimitNames: input.personalOverLimit ?? [] }, + ]) + queueTableRows(workspaceEnvironment, [ + { variables: input.workspace ?? {}, overLimitNames: input.workspaceOverLimit ?? [] }, + ]) + queueTableRows(credential, input.credentials ?? []) +} + +function credentialRow( + overrides: Partial & Pick +): CredentialRow { + return { + envOwnerUserId: null, + role: null, + status: null, + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + encryptedValue: null, + encryptedValueBytes: null, + ...overrides, + } +} + +function mockSqlText(value: unknown): string { + if (typeof value !== 'object' || value === null || !('toSQL' in value)) { + throw new Error('Expected a mock SQL fragment') + } + const toSQL = value.toSQL + if (typeof toSQL !== 'function') throw new Error('Expected a mock SQL renderer') + const rendered = toSQL.call(value) as { sql?: unknown } + if (typeof rendered.sql !== 'string') throw new Error('Expected rendered SQL text') + return rendered.sql +} + +describe('materializeCopilotCodeSecrets', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: false, + }) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({ + decrypted: `plain:${encryptedValue}`, + })) + }) + + it('mounts the actor own personal secret', async () => { + queueSources({ personal: { API_KEY: 'personal-cipher' } }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).resolves.toEqual({ + envVars: { API_KEY: 'plain:personal-cipher' }, + catalogEntries: [ + { name: 'API_KEY', plaintext: 'plain:personal-cipher', encryptedValue: 'personal-cipher' }, + ], + }) + }) + + it('mounts an own __proto__ secret as data without mutating record prototypes', async () => { + queueSources({ personal: Object.fromEntries([['__proto__', 'personal-cipher']]) }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['__proto__'], + }) + + expect(Object.hasOwn(result.envVars, '__proto__')).toBe(true) + expect(result.envVars.__proto__).toBe('plain:personal-cipher') + expect(Object.getPrototypeOf(result.envVars)).toBe(Object.prototype) + }) + + it('casts stored JSON values before using JSONB operators', async () => { + queueSources({ personal: { API_KEY: 'personal-cipher' } }) + + await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + for (const [selection] of dbChainMockFns.select.mock.calls.slice(0, 2)) { + const fields = selection as Record + expect(mockSqlText(fields.variables)).toContain("coalesce(?::jsonb, '{}'::jsonb)") + expect(mockSqlText(fields.overLimitNames)).toContain("coalesce(?::jsonb, '{}'::jsonb)") + } + + const personalCredentialPredicate = vi.mocked(or).mock.calls[0]?.[1] + expect(mockSqlText(personalCredentialPredicate)).toContain( + "coalesce(?::jsonb, '{}'::jsonb) ? ?" + ) + }) + + it('lets a workspace admin mount workspace secrets with workspace precedence', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspace: { API_KEY: 'workspace-cipher' }, + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:workspace-cipher' }) + }) + + it('lets an active per-secret admin mount a workspace secret', async () => { + queueSources({ + workspace: { API_KEY: 'workspace-cipher' }, + credentials: [ + credentialRow({ + type: 'env_workspace', + envKey: 'API_KEY', + role: 'admin', + status: 'active', + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:workspace-cipher' }) + }) + + it.each([ + ['member', 'active'], + ['admin', 'revoked'], + ['admin', 'pending'], + ] as const)('denies a workspace secret for a %s/%s credential grant', async (role, status) => { + queueSources({ + workspace: { API_KEY: 'workspace-cipher' }, + credentials: [credentialRow({ type: 'env_workspace', envKey: 'API_KEY', role, status })], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toBeInstanceOf(CopilotCodeSecretAccessError) + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('denies workspace secrets when the actor has zero credential grants', async () => { + queueSources({ workspace: { API_KEY: 'workspace-cipher' }, credentials: [] }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Copilot code cannot access the requested secret: API_KEY') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('lets an authorized personal value win over an unauthorized same-name workspace value', async () => { + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspace: { API_KEY: 'workspace-cipher' }, + credentials: [ + credentialRow({ + type: 'env_workspace', + envKey: 'API_KEY', + role: 'member', + status: 'active', + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:personal-cipher' }) + }) + + it('lets an authorized personal value win over an unauthorized over-limit workspace value', async () => { + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspaceOverLimit: ['API_KEY'], + credentials: [ + credentialRow({ + type: 'env_workspace', + envKey: 'API_KEY', + role: 'member', + status: 'active', + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:personal-cipher' }) + }) + + it('does not fall back when an authorized workspace value exceeds the encrypted byte limit', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspaceOverLimit: ['API_KEY'], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('does not fall back when the actor own personal value exceeds the encrypted byte limit', async () => { + queueSources({ + personalOverLimit: ['API_KEY'], + credentials: [ + credentialRow({ + type: 'env_personal', + envKey: 'API_KEY', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + encryptedValue: 'shared-cipher', + encryptedValueBytes: 13, + }), + ], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('mounts another owner personal secret only for an active per-secret admin', async () => { + queueSources({ + credentials: [ + credentialRow({ + type: 'env_personal', + envKey: 'SHARED_KEY', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + encryptedValue: 'shared-cipher', + encryptedValueBytes: 13, + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['SHARED_KEY'], + }) + + expect(result.envVars).toEqual({ SHARED_KEY: 'plain:shared-cipher' }) + }) + + it('uses the current encrypted value on every call so rotation is observed', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueSources({ workspace: { API_KEY: 'rotated-cipher' } }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:rotated-cipher' }) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledWith('rotated-cipher') + }) + + it('fails atomically for missing or deleted names before decrypting authorized values', async () => { + queueSources({ personal: { ALLOWED: 'allowed-cipher' } }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['ALLOWED', 'DELETED'], + }) + ).rejects.toThrow('Copilot code cannot access the requested secret: DELETED') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('fails the whole call when decryption fails', async () => { + queueSources({ personal: { API_KEY: 'broken-cipher' } }) + encryptionMockFns.mockDecryptSecret.mockRejectedValue(new Error('decrypt failed')) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('One or more requested secrets could not be decrypted') + }) + + it('fails the whole call when mounted plaintext exceeds the byte budget', async () => { + queueSources({ personal: { API_KEY: 'large-cipher' } }) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'x'.repeat(64 * 1024 + 1) }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + }) + + it('fails the whole call when an authorized shared personal ciphertext exceeds the byte limit', async () => { + queueSources({ + credentials: [ + credentialRow({ + type: 'env_personal', + envKey: 'API_KEY', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + encryptedValueBytes: 512 * 1024 + 1, + }), + ], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('rejects over-limit requests before access checks, database reads, or decryption', async () => { + const requestedNames = Array.from( + { length: MAX_SECRET_MOUNT_NAMES + 1 }, + (_, index) => `SECRET_${index}` + ) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames, + }) + ).rejects.toThrow(`at most ${MAX_SECRET_MOUNT_NAMES} secrets`) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('rejects overlong names before access checks, database reads, or decryption', async () => { + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['S'.repeat(MAX_SECRET_MOUNT_NAME_LENGTH + 1)], + }) + ).rejects.toThrow(`at most ${MAX_SECRET_MOUNT_NAME_LENGTH} characters`) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts new file mode 100644 index 00000000000..47d81467c8f --- /dev/null +++ b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts @@ -0,0 +1,312 @@ +import { db } from '@sim/db' +import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema' +import { and, desc, eq, inArray, or, sql } from 'drizzle-orm' +import type { AnyPgColumn } from 'drizzle-orm/pg-core' +import { + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, +} from '@/lib/copilot/secret-mount-policy' +import { decryptSecret } from '@/lib/core/security/encryption' +import { setRecordValue } from '@/lib/core/utils/records' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import type { ResolvedSecretTraceCatalogEntry } from '@/executor/utils/resolved-secret-trace-registry' + +export { MAX_SECRET_MOUNT_NAME_LENGTH, MAX_SECRET_MOUNT_NAMES } + +const MAX_SECRET_MOUNT_ENCRYPTED_BYTES = 512 * 1024 +const MAX_SECRET_MOUNT_PLAINTEXT_BYTES = 64 * 1024 +const MAX_SECRET_MOUNT_TOTAL_PLAINTEXT_BYTES = 256 * 1024 + +interface CredentialAccessRow { + type: 'env_personal' | 'env_workspace' + envKey: string + envOwnerUserId: string | null + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + updatedAt: Date + encryptedValue: string | null + encryptedValueBytes: number | null +} + +interface AuthorizedEncryptedSecret { + name: string + encryptedValue: string +} + +export interface MaterializedCopilotCodeSecrets { + envVars: Record + catalogEntries: ResolvedSecretTraceCatalogEntry[] +} + +export class CopilotCodeSecretAccessError extends Error { + constructor(message: string) { + super(message) + this.name = 'CopilotCodeSecretAccessError' + } +} + +function normalizeRequestedNames(names: readonly string[]): string[] { + const normalized: string[] = [] + const seen = new Set() + for (const name of names) { + if (name.length === 0 || seen.has(name)) continue + if (name.length > MAX_SECRET_MOUNT_NAME_LENGTH) { + throw new CopilotCodeSecretAccessError( + `Copilot secret names may be at most ${MAX_SECRET_MOUNT_NAME_LENGTH} characters` + ) + } + seen.add(name) + normalized.push(name) + } + if (normalized.length > MAX_SECRET_MOUNT_NAMES) { + throw new CopilotCodeSecretAccessError( + `Copilot code may request at most ${MAX_SECRET_MOUNT_NAMES} secrets per call` + ) + } + return normalized +} + +function encryptedVariables(row: { variables: unknown } | undefined): Record { + if (!row?.variables || typeof row.variables !== 'object' || Array.isArray(row.variables)) + return {} + const result: Record = {} + for (const [name, value] of Object.entries(row.variables)) { + if (typeof value === 'string') setRecordValue(result, name, value) + } + return result +} + +function requestedVariables(column: AnyPgColumn, names: readonly string[]) { + const keys = sql.join( + names.map((name) => sql`${name}`), + sql`, ` + ) + return sql>`coalesce( + ( + select jsonb_object_agg(entry.key, entry.value) + from jsonb_each_text(coalesce(${column}::jsonb, '{}'::jsonb)) as entry(key, value) + where entry.key in (${keys}) + and octet_length(entry.value) <= ${MAX_SECRET_MOUNT_ENCRYPTED_BYTES} + ), + '{}'::jsonb + )`.as('variables') +} + +function requestedOverLimitNames(column: AnyPgColumn, names: readonly string[]) { + const keys = sql.join( + names.map((name) => sql`${name}`), + sql`, ` + ) + return sql`coalesce( + ( + select jsonb_agg(entry.key order by entry.key) + from jsonb_each_text(coalesce(${column}::jsonb, '{}'::jsonb)) as entry(key, value) + where entry.key in (${keys}) + and octet_length(entry.value) > ${MAX_SECRET_MOUNT_ENCRYPTED_BYTES} + ), + '[]'::jsonb + )`.as('over_limit_names') +} + +function overLimitNames(row: { overLimitNames?: unknown } | undefined): Set { + if (!Array.isArray(row?.overLimitNames)) return new Set() + return new Set(row.overLimitNames.filter((name): name is string => typeof name === 'string')) +} + +function activeAdmin(row: CredentialAccessRow): boolean { + return row.role === 'admin' && row.status === 'active' +} + +function unavailableError(names: readonly string[]): CopilotCodeSecretAccessError { + return new CopilotCodeSecretAccessError( + `Copilot code cannot access the requested secret${names.length === 1 ? '' : 's'}: ${names.join(', ')}` + ) +} + +/** + * Resolves exact Secrets-tab values for arbitrary Copilot code after rechecking current authority. + * No plaintext is produced until every requested name has an authorized source. + */ +export async function materializeCopilotCodeSecrets(params: { + actorUserId: string + workspaceId: string + requestedNames: readonly string[] +}): Promise { + const requestedNames = normalizeRequestedNames(params.requestedNames) + if (requestedNames.length === 0) return { envVars: {}, catalogEntries: [] } + + const access = await checkWorkspaceAccess(params.workspaceId, params.actorUserId) + if (!access.exists || !access.canWrite) { + throw new CopilotCodeSecretAccessError( + 'Write access is required to mount secrets into Copilot code' + ) + } + + const [personalRows, workspaceRows, credentialRows] = await Promise.all([ + db + .select({ + variables: requestedVariables(environment.variables, requestedNames), + overLimitNames: requestedOverLimitNames(environment.variables, requestedNames), + }) + .from(environment) + .where(eq(environment.userId, params.actorUserId)) + .limit(1), + db + .select({ + variables: requestedVariables(workspaceEnvironment.variables, requestedNames), + overLimitNames: requestedOverLimitNames(workspaceEnvironment.variables, requestedNames), + }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, params.workspaceId)) + .limit(1), + db + .selectDistinctOn([credential.type, credential.envKey], { + type: credential.type, + envKey: credential.envKey, + envOwnerUserId: credential.envOwnerUserId, + role: credentialMember.role, + status: credentialMember.status, + updatedAt: credential.updatedAt, + encryptedValue: sql`case + when octet_length(${environment.variables} ->> ${credential.envKey}) <= ${MAX_SECRET_MOUNT_ENCRYPTED_BYTES} + then ${environment.variables} ->> ${credential.envKey} + else null + end`.as('encrypted_value'), + encryptedValueBytes: sql< + number | null + >`octet_length(${environment.variables} ->> ${credential.envKey})`.as( + 'encrypted_value_bytes' + ), + }) + .from(credential) + .innerJoin( + credentialMember, + and( + eq(credentialMember.credentialId, credential.id), + eq(credentialMember.userId, params.actorUserId) + ) + ) + .leftJoin(environment, eq(environment.userId, credential.envOwnerUserId)) + .where( + and( + eq(credential.workspaceId, params.workspaceId), + inArray(credential.type, ['env_workspace', 'env_personal']), + inArray(credential.envKey, requestedNames), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active'), + or( + eq(credential.type, 'env_workspace'), + sql`coalesce(${environment.variables}::jsonb, '{}'::jsonb) ? ${credential.envKey}` + ) + ) + ) + .orderBy(credential.type, credential.envKey, desc(credential.updatedAt)) + .limit(MAX_SECRET_MOUNT_NAMES * 2), + ]) + + const ownPersonalEncrypted = encryptedVariables(personalRows[0]) + const workspaceEncrypted = encryptedVariables(workspaceRows[0]) + const ownPersonalOverLimit = overLimitNames(personalRows[0]) + const workspaceOverLimit = overLimitNames(workspaceRows[0]) + const envCredentialRows = credentialRows.filter( + (row): row is CredentialAccessRow => + (row.type === 'env_personal' || row.type === 'env_workspace') && + typeof row.envKey === 'string' + ) + const authorizedSharedPersonalRows = envCredentialRows.filter( + (row) => + row.type === 'env_personal' && + row.envOwnerUserId !== null && + row.envOwnerUserId !== params.actorUserId && + activeAdmin(row) + ) + + const authorizedSources: AuthorizedEncryptedSecret[] = [] + const unavailable: string[] = [] + const overLimit: string[] = [] + for (const name of requestedNames) { + const workspaceValue = workspaceEncrypted[name] + const workspaceExists = workspaceValue !== undefined || workspaceOverLimit.has(name) + const workspaceAuthorized = + workspaceExists && + (access.canAdmin || + envCredentialRows.some( + (row) => row.type === 'env_workspace' && row.envKey === name && activeAdmin(row) + )) + + if (workspaceAuthorized) { + if (workspaceValue === undefined) { + overLimit.push(name) + continue + } + authorizedSources.push({ name, encryptedValue: workspaceValue }) + continue + } + + const ownPersonalValue = ownPersonalEncrypted[name] + if (ownPersonalOverLimit.has(name)) { + overLimit.push(name) + continue + } + if (ownPersonalValue !== undefined) { + authorizedSources.push({ name, encryptedValue: ownPersonalValue }) + continue + } + + const sharedPersonal = authorizedSharedPersonalRows + .filter((row) => row.envKey === name) + .sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime()) + .at(0) + if ( + sharedPersonal && + sharedPersonal.encryptedValueBytes !== null && + sharedPersonal.encryptedValueBytes > MAX_SECRET_MOUNT_ENCRYPTED_BYTES + ) { + overLimit.push(name) + continue + } + const sharedPersonalValue = sharedPersonal?.encryptedValue ?? undefined + if (sharedPersonalValue !== undefined) { + authorizedSources.push({ name, encryptedValue: sharedPersonalValue }) + continue + } + + unavailable.push(name) + } + + if (overLimit.length > 0) { + throw new CopilotCodeSecretAccessError('Requested secrets exceed the Copilot mount size limit') + } + if (unavailable.length > 0) throw unavailableError(unavailable) + + let decryptedEntries: Array<{ name: string; plaintext: string; encryptedValue: string }> + try { + decryptedEntries = await Promise.all( + authorizedSources.map(async ({ name, encryptedValue }) => { + const { decrypted } = await decryptSecret(encryptedValue) + return { name, plaintext: decrypted, encryptedValue } + }) + ) + } catch { + throw new CopilotCodeSecretAccessError('One or more requested secrets could not be decrypted') + } + + let totalPlaintextBytes = 0 + for (const entry of decryptedEntries) { + const plaintextBytes = Buffer.byteLength(entry.plaintext, 'utf8') + totalPlaintextBytes += plaintextBytes + if ( + plaintextBytes > MAX_SECRET_MOUNT_PLAINTEXT_BYTES || + totalPlaintextBytes > MAX_SECRET_MOUNT_TOTAL_PLAINTEXT_BYTES + ) { + throw new CopilotCodeSecretAccessError( + 'Requested secrets exceed the Copilot mount size limit' + ) + } + } + + return { + envVars: Object.fromEntries(decryptedEntries.map((entry) => [entry.name, entry.plaintext])), + catalogEntries: decryptedEntries, + } +} diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts new file mode 100644 index 00000000000..f8d84b3fe93 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts @@ -0,0 +1,16 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { computeBlockLevelInputs } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' +import { MothershipBlock } from '@/blocks/blocks/mothership' + +describe('get blocks metadata', () => { + it('omits server-only Mothership policy inputs from block metadata definitions', () => { + const definitions = computeBlockLevelInputs(MothershipBlock) + + expect(definitions).not.toHaveProperty('secretScope') + expect(definitions).not.toHaveProperty('mountedSecrets') + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 6dbb214de80..5a7c1f2d3a9 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -180,7 +180,9 @@ export const getBlocksMetadataServerTool: BaseServerTool< // `workflow_executor`; the agent never configures a workflowId/inputMapping. // Present it as self-contained: its visible input fields + curated outputs, // no tools/operations. - const visibleSubBlocks = (blockConfig.subBlocks || []).filter((sb) => !sb.hidden) + const visibleSubBlocks = (blockConfig.subBlocks || []).filter( + (sb) => !sb.hidden && !sb.hideFromCopilot + ) const outputs = blockConfig.outputs ? Object.fromEntries( Object.entries(blockConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def)) @@ -273,11 +275,13 @@ export const getBlocksMetadataServerTool: BaseServerTool< }) } - const blockInputs = computeBlockLevelInputs(blockConfig) + const hiddenParamKeys = getCopilotHiddenParamKeys(blockConfig) + const blockInputs = computeBlockLevelInputs(blockConfig, hiddenParamKeys) const { commonParameters, operationParameters } = splitParametersByOperation( Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks.filter( - (sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' + (sb) => + !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' ) : [], blockInputs @@ -297,7 +301,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< : {} const filteredToolParams: Record = {} for (const [k, v] of Object.entries(toolParams)) { - if (!(k in blockInputs)) filteredToolParams[k] = v + if (!(k in blockInputs) && !hiddenParamKeys.has(k)) filteredToolParams[k] = v } operations[opId] = { toolId: resolvedToolId, @@ -968,10 +972,25 @@ function splitParametersByOperation( return { commonParameters, operationParameters } } -function computeBlockLevelInputs(blockConfig: BlockConfig): Record { +function getCopilotHiddenParamKeys(blockConfig: BlockConfig): Set { + const hiddenParamKeys = new Set() + for (const subBlock of blockConfig.subBlocks ?? []) { + if (!subBlock.hideFromCopilot) continue + if (subBlock.id) hiddenParamKeys.add(subBlock.id) + if (subBlock.canonicalParamId) hiddenParamKeys.add(subBlock.canonicalParamId) + } + return hiddenParamKeys +} + +export function computeBlockLevelInputs( + blockConfig: BlockConfig, + hiddenParamKeys = getCopilotHiddenParamKeys(blockConfig) +): Record { const inputs = blockConfig.inputs || {} const subBlocks: any[] = Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced') + ? blockConfig.subBlocks.filter( + (sb) => !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' + ) : [] const byParamKey: Record = {} @@ -988,6 +1007,7 @@ function computeBlockLevelInputs(blockConfig: BlockConfig): Record const blockInputs: Record = {} for (const key of Object.keys(inputs)) { + if (hiddenParamKeys.has(key)) continue const sbs = byParamKey[key] || [] const isOperationGated = sbs.some((sb) => { const cond = normalizeCondition(sb.condition) @@ -1006,7 +1026,9 @@ function computeOperationLevelInputs( ): Record> { const inputs = blockConfig.inputs || {} const subBlocks = Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced') + ? blockConfig.subBlocks.filter( + (sb) => !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' + ) : [] const opInputs: Record> = {} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index 295ba44db98..8ae05a41465 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -150,6 +150,17 @@ const genericWebhookBlockConfig = { ], } +const mothershipBlockConfig = { + type: 'mothership', + name: 'Sim Chat', + outputs: {}, + subBlocks: [ + { id: 'prompt', type: 'long-input' }, + { id: 'secretScope', type: 'dropdown', hideFromCopilot: true }, + { id: 'mountedSecrets', type: 'dropdown', hideFromCopilot: true }, + ], +} + // Block whose tool selector throws — should fall back to scanning access tools (video_falai). const throwSelectorBlockConfig = { type: 'throw_selector_block', @@ -204,6 +215,7 @@ const blockConfigsByType: Record = { throw_gate_block: throwGateBlockConfig, throw_selector_block: throwSelectorBlockConfig, generic_webhook: genericWebhookBlockConfig, + mothership: mothershipBlockConfig, } vi.mock('@/blocks/registry', () => ({ @@ -358,6 +370,17 @@ describe('validateInputsForBlock', () => { expect(result.errors[0]?.error).toContain('read-only') }) + it('rejects server-only Sim Chat secret-mount policy inputs', () => { + const result = validateInputsForBlock( + 'mothership', + { prompt: 'Keep this', secretScope: 'all', mountedSecrets: ['API_KEY'] }, + 'chat-1' + ) + + expect(result.validInputs).toEqual({ prompt: 'Keep this' }) + expect(result.errors.map((error) => error.field)).toEqual(['secretScope', 'mountedSecrets']) + }) + it('accepts known agent model ids', () => { const result = validateInputsForBlock('agent', { model: 'claude-sonnet-4-6' }, 'agent-1') diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 36bc722a0f8..48d44f21dbc 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -128,6 +128,17 @@ export function validateInputsForBlock( continue } + if (subBlockConfig.hideFromCopilot === true) { + errors.push({ + blockId, + blockType, + field: key, + value, + error: `Field "${key}" on block type "${blockType}" is server-managed and cannot be set by Copilot`, + }) + continue + } + // Note: We do NOT check subBlockConfig.condition here. // Conditions are for UI display logic (show/hide fields in the editor). // For API/Copilot, any valid field in the block schema should be accepted. diff --git a/apps/sim/lib/copilot/tools/workflow-tools.ts b/apps/sim/lib/copilot/tools/workflow-tools.ts index fc750614dfb..c7c6c103d92 100644 --- a/apps/sim/lib/copilot/tools/workflow-tools.ts +++ b/apps/sim/lib/copilot/tools/workflow-tools.ts @@ -1,3 +1,9 @@ +import { isPlainRecord } from '@sim/utils/object' +import { + ASYNC_TOOL_CONFIRMATION_STATUS, + type AsyncConfirmationStatus, +} from '@/lib/copilot/async-runs/lifecycle' + const WORKFLOW_TOOL_NAMES = [ 'run_workflow', 'run_workflow_until_block', @@ -10,3 +16,64 @@ const WORKFLOW_TOOL_NAME_SET = new Set(WORKFLOW_TOOL_NAMES) export function isWorkflowToolName(name: string): boolean { return WORKFLOW_TOOL_NAME_SET.has(name) } + +/** Resolves the workflow target from immutable tool arguments, then the owning Copilot run. */ +export function resolveWorkflowToolTargetId( + args: unknown, + runWorkflowId?: string | null +): string | undefined { + if (isPlainRecord(args) && typeof args.workflowId === 'string' && args.workflowId.length > 0) { + return args.workflowId + } + return typeof runWorkflowId === 'string' && runWorkflowId.length > 0 ? runWorkflowId : undefined +} + +export function getWorkflowToolCompletionExecutionId(data: unknown): string | undefined { + if (!isPlainRecord(data)) return undefined + return typeof data.executionId === 'string' && data.executionId.length > 0 + ? data.executionId + : undefined +} + +export function getWorkflowToolCompletionMessage(status: AsyncConfirmationStatus): string { + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.success) { + return 'Workflow execution completed.' + } + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled) { + return 'Workflow execution was cancelled.' + } + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + return 'Workflow execution is continuing in the background.' + } + return 'Workflow execution failed.' +} + +export function getWorkflowToolConfirmationStatus( + status: 'completed' | 'failed' | 'cancelled' +): AsyncConfirmationStatus { + if (status === 'completed') return ASYNC_TOOL_CONFIRMATION_STATUS.success + if (status === 'cancelled') return ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + return ASYNC_TOOL_CONFIRMATION_STATUS.error +} + +export function createStructuralWorkflowToolCompletionData( + status: AsyncConfirmationStatus, + workflowId?: string, + executionId?: string +): Record { + const data: Record = {} + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.success) data.success = true + if ( + status === ASYNC_TOOL_CONFIRMATION_STATUS.error || + status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + ) { + data.success = false + } + if (workflowId) data.workflowId = workflowId + if (executionId) data.executionId = executionId + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled) { + data.reason = 'user_cancelled' + data.cancelledByUser = true + } + return data +} diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index daf3c3c8086..4394b746e0b 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -217,6 +217,44 @@ describe('hosted-key VFS metadata', () => { expect(schema.inputs.apiKey).toBeDefined() expect(schema.toolAuth.search.mode).toBe('hosted_or_byok') }) + + it('omits server-only lifecycle inputs from block schemas', () => { + const block = { + type: 'mothership', + name: 'Sim Chat', + description: 'Talk to Sim', + category: 'blocks', + bgColor: '#000000', + icon: () => null, + subBlocks: [ + { id: 'prompt', title: 'Prompt', type: 'long-input' }, + { + id: 'secretScope', + title: 'Secret access', + type: 'dropdown', + hideFromCopilot: true, + }, + { + id: 'mountedSecrets', + title: 'Secrets', + type: 'dropdown', + hideFromCopilot: true, + }, + ], + tools: { access: [] }, + inputs: { + prompt: { type: 'string' }, + secretScope: { type: 'string' }, + mountedSecrets: { type: 'json' }, + }, + outputs: {}, + } as unknown as BlockConfig + + const schema = JSON.parse(serializeBlockSchema(block)) + + expect(schema.subBlocks.map((subBlock: { id: string }) => subBlock.id)).toEqual(['prompt']) + expect(schema.inputs).toEqual({ prompt: { type: 'string' } }) + }) }) describe('serializeKBMeta', () => { diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 0972b7a0d1f..5dd5959a86e 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -573,12 +573,14 @@ export function serializeBlockSchema( const customBlock = isCustomBlockType(block.type) const hosted = options?.hosted ?? isHosted const visibleSubBlocks = block.subBlocks.filter( - (sb) => !isSubBlockHidden(sb, { hosted }) && !(customBlock && sb.hidden) + (sb) => !sb.hideFromCopilot && !isSubBlockHidden(sb, { hosted }) && !(customBlock && sb.hidden) ) const visibleIds = new Set(visibleSubBlocks.map((sb) => sb.id)) const hiddenIds = new Set( block.subBlocks - .filter((sb) => isSubBlockHidden(sb, { hosted }) || (customBlock && sb.hidden)) + .filter( + (sb) => sb.hideFromCopilot || isSubBlockHidden(sb, { hosted }) || (customBlock && sb.hidden) + ) .map((sb) => sb.id) .filter((id) => !visibleIds.has(id)) ) diff --git a/apps/sim/lib/core/async-jobs/types.ts b/apps/sim/lib/core/async-jobs/types.ts index 12b852165f1..9a1ee04aefa 100644 --- a/apps/sim/lib/core/async-jobs/types.ts +++ b/apps/sim/lib/core/async-jobs/types.ts @@ -39,6 +39,8 @@ export interface AsyncExecutionCorrelation { requestId: string source: AsyncExecutionCorrelationSource workflowId: string + /** Server-validated binding for a browser-routed Copilot workflow tool execution. */ + copilotToolCallId?: string triggerType?: string webhookId?: string scheduleId?: string diff --git a/apps/sim/lib/core/utils/records.test.ts b/apps/sim/lib/core/utils/records.test.ts index 80195d71ccc..9bc22e8f07e 100644 --- a/apps/sim/lib/core/utils/records.test.ts +++ b/apps/sim/lib/core/utils/records.test.ts @@ -29,6 +29,14 @@ describe('record normalization utilities', () => { expect(normalizeStringRecord([])).toEqual({}) }) + it('preserves own __proto__ keys without changing the record prototype', () => { + const normalized = normalizeStringRecord(Object.fromEntries([['__proto__', 'secret-value']])) + + expect(Object.hasOwn(normalized, '__proto__')).toBe(true) + expect(normalized.__proto__).toBe('secret-value') + expect(Object.getPrototypeOf(normalized)).toBe(Object.prototype) + }) + it('normalizes record maps by dropping malformed entries', () => { expect( normalizeRecordMap({ diff --git a/apps/sim/lib/core/utils/records.ts b/apps/sim/lib/core/utils/records.ts index b13554b5c54..aea457c67d1 100644 --- a/apps/sim/lib/core/utils/records.ts +++ b/apps/sim/lib/core/utils/records.ts @@ -3,6 +3,15 @@ import { isPlainRecord } from '@sim/utils/object' export type UnknownRecord = Record export type StringRecord = Record +export function setRecordValue(record: Record, key: string, value: unknown): void { + Object.defineProperty(record, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }) +} + /** * Normalizes optional execution context maps to the record shape expected by * internal API contracts. @@ -25,7 +34,11 @@ export function normalizeStringRecord(value: unknown): StringRecord { if (entryValue === undefined || entryValue === null) { continue } - normalized[key] = typeof entryValue === 'string' ? entryValue : String(entryValue) + setRecordValue( + normalized, + key, + typeof entryValue === 'string' ? entryValue : String(entryValue) + ) } return normalized } @@ -41,7 +54,7 @@ export function normalizeRecordMap(value: unknown): Record = {} for (const [key, entryValue] of Object.entries(value)) { if (isPlainRecord(entryValue)) { - normalized[key] = entryValue + setRecordValue(normalized, key, entryValue) } } return normalized @@ -72,7 +85,7 @@ export function normalizeWorkflowVariables(value: unknown): UnknownRecord { const key = id ?? name if (key) { - normalized[key] = variable + setRecordValue(normalized, key, variable) } } diff --git a/apps/sim/lib/credentials/atlassian-service-account.ts b/apps/sim/lib/credentials/atlassian-service-account.ts index 1d78cd8bd6b..fa4381d31a9 100644 --- a/apps/sim/lib/credentials/atlassian-service-account.ts +++ b/apps/sim/lib/credentials/atlassian-service-account.ts @@ -80,7 +80,16 @@ async function assertAtlassianResponseOk( export async function validateAtlassianServiceAccount( apiToken: string, domain: string -): Promise<{ accountId: string; displayName: string; cloudId: string }> { +): Promise<{ + accountId: string + displayName: string + cloudId: string + /** + * Only present when the site's profile-visibility settings expose it to the + * calling token; absence is never a validation failure. + */ + emailAddress?: string +}> { assertAtlassianCloudHost(domain) const tenantInfoRes = await fetch(`https://${domain}/_edge/tenant_info`, { @@ -123,5 +132,6 @@ export async function validateAtlassianServiceAccount( accountId: myself.accountId, displayName: myself.displayName || myself.emailAddress || domain, cloudId, + ...(myself.emailAddress ? { emailAddress: myself.emailAddress } : {}), } } diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index f2439cabd38..ef83609cee2 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -274,7 +274,7 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/salesforce-service-account', helpText: - 'The Connected App must have "Enable Client Credentials Flow" checked with a "Run As" integration user set under Edit Policies — every call executes with that user\'s permissions, and deactivating or freezing the user stops all runs.', + 'The Connected App must have "Enable Client Credentials Flow" checked with a "Run As" integration user set under Edit Policies — every call executes with that user\'s permissions, and deactivating or freezing the user stops all runs. Selecting the "openid" scope lets Sim record which run-as user the credential authenticates as; without it the connection still works but the identity is not captured.', }, [ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID, diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts index f380e8d8742..7b09b9b2ad5 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts @@ -67,6 +67,7 @@ describe('mintBoxServiceAccountToken', () => { .mockResolvedValueOnce(jsonResponse(200, { access_token: 'box-access', expires_in: 3600 })) .mockResolvedValueOnce( jsonResponse(200, { + id: '33445566', name: 'Sim Automation', login: 'AutomationUser_123_abc@boxdevedition.com', }) @@ -79,14 +80,13 @@ describe('mintBoxServiceAccountToken', () => { expiresInSeconds: 3600, identity: { displayName: 'Sim Automation', - auditMetadata: { - boxEnterpriseId: '1234567', - boxServiceAccountLogin: 'AutomationUser_123_abc@boxdevedition.com', - }, - storedMetadata: { - enterpriseId: '1234567', - serviceAccountLogin: 'AutomationUser_123_abc@boxdevedition.com', + principal: { + kind: 'user', + id: '33445566', + label: 'AutomationUser_123_abc@boxdevedition.com', }, + auditMetadata: { boxEnterpriseId: '1234567' }, + storedMetadata: { enterpriseId: '1234567' }, }, }) expect(mockFetch).toHaveBeenCalledTimes(2) @@ -94,7 +94,7 @@ describe('mintBoxServiceAccountToken', () => { expectIdentityCall() }) - it('still succeeds with a fallback identity when users/me fails', async () => { + it('marks the principal as lookup_failed when users/me fails', async () => { mockFetch .mockResolvedValueOnce(jsonResponse(200, { access_token: 'box-access', expires_in: 2400 })) .mockResolvedValueOnce(jsonResponse(500, { message: 'boom' })) @@ -105,8 +105,26 @@ describe('mintBoxServiceAccountToken', () => { expect(result.expiresInSeconds).toBe(2400) expect(result.identity).toEqual({ displayName: 'Box enterprise 1234567', + principal: { kind: 'lookup_failed', reason: 'HTTP 500' }, auditMetadata: { boxEnterpriseId: '1234567' }, + storedMetadata: { enterpriseId: '1234567' }, + }) + }) + + it('marks the principal as lookup_failed when users/me omits the user id', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: 'box-access', expires_in: 3600 })) + .mockResolvedValueOnce(jsonResponse(200, { name: 'Sim Automation' })) + + const result = await mintBoxServiceAccountToken(FIELDS) + + expect(result.identity?.principal).toEqual({ + kind: 'lookup_failed', + reason: 'response missing user id', }) + // Only the principal degrades — a name that did come back still beats the + // Enterprise-ID fallback, so the credential does not lose its label. + expect(result.identity?.displayName).toBe('Sim Automation') }) it('still succeeds when the identity request itself throws', async () => { @@ -118,6 +136,10 @@ describe('mintBoxServiceAccountToken', () => { expect(result.accessToken).toBe('box-access') expect(result.identity?.displayName).toBe('Box enterprise 1234567') + expect(result.identity?.principal).toEqual({ + kind: 'lookup_failed', + reason: 'provider_unavailable (HTTP 502)', + }) }) it('throws invalid_credentials on 400 invalid_client', async () => { diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts index 720631d072d..25e3b609080 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts @@ -1,3 +1,5 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import type { ClientCredentialAccountFields, ClientCredentialAccountIdentity, @@ -8,10 +10,15 @@ import { fetchProvider, isTransientProviderStatus, parseProviderJson, + providerFailureReason, readProviderErrorSnippet, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' +const logger = createLogger('BoxServiceAccountMinter') + +const IDENTITY_STEP = 'box_identity' + const BOX_TOKEN_URL = 'https://api.box.com/oauth2/token' const BOX_CURRENT_USER_URL = 'https://api.box.com/2.0/users/me' @@ -20,7 +27,14 @@ interface BoxTokenResponse { expires_in?: number } +/** + * `id`, `name`, and `login` are all in the standard field set `GET /2.0/users/me` + * returns without a `fields` parameter, so capturing the Service Account's user + * id costs no extra request. + * @see https://developer.box.com/reference/get-users-me/ + */ interface BoxCurrentUserResponse { + id?: string name?: string login?: string } @@ -53,40 +67,67 @@ function boxErrorHint(body: string): string | undefined { /** * Best-effort identity lookup for the app's Service Account user. A failure - * never fails the mint — the caller falls back to an Enterprise-ID-derived - * display name. + * never fails the mint — the credential degrades to an Enterprise-ID-derived + * display name with a `lookup_failed` principal, so the audit record shows the + * identity was not captured rather than implying none exists. */ async function fetchBoxServiceAccountIdentity( accessToken: string, orgId: string ): Promise { - const fallback: ClientCredentialAccountIdentity = { - displayName: `Box enterprise ${orgId}`, + /** + * `label` keeps whatever human name the lookup did return. A response can + * carry `name`/`login` but no `id` — the principal is then unusable, but the + * label still beats the Enterprise-ID fallback, so only the principal + * degrades and the credential does not silently lose its name. + */ + const degraded = (reason: string, label?: string): ClientCredentialAccountIdentity => ({ + displayName: label ?? `Box enterprise ${orgId}`, + principal: { kind: 'lookup_failed', reason }, auditMetadata: { boxEnterpriseId: orgId }, - } + storedMetadata: { enterpriseId: orgId }, + }) try { const res = await fetchProvider( BOX_CURRENT_USER_URL, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } }, - 'box_identity' + IDENTITY_STEP ) - if (!res.ok) return fallback - const user = await parseProviderJson(res, 'box_identity') + if (!res.ok) { + logger.warn('Box service-account identity lookup failed', { + step: IDENTITY_STEP, + status: res.status, + enterpriseId: orgId, + }) + return degraded(`HTTP ${res.status}`) + } + const user = await parseProviderJson(res, IDENTITY_STEP) + const id = typeof user.id === 'string' && user.id ? user.id : undefined const login = typeof user.login === 'string' && user.login ? user.login : undefined const name = typeof user.name === 'string' && user.name ? user.name : undefined - return { - displayName: name ?? login ?? fallback.displayName, - auditMetadata: { - boxEnterpriseId: orgId, - ...(login ? { boxServiceAccountLogin: login } : {}), - }, - storedMetadata: { + if (!id) { + logger.warn('Box service-account identity response carried no user id', { + step: IDENTITY_STEP, + status: res.status, enterpriseId: orgId, - ...(login ? { serviceAccountLogin: login } : {}), - }, + }) + return degraded('response missing user id', name ?? login) } - } catch { - return fallback + return { + displayName: name ?? login ?? `Box enterprise ${orgId}`, + // The Service Account is a real Box user; `enterpriseId` is shared by + // every app in the enterprise and so is kept as separate context. + principal: { kind: 'user', id, ...(login ? { label: login } : {}) }, + auditMetadata: { boxEnterpriseId: orgId }, + storedMetadata: { enterpriseId: orgId }, + } + } catch (error) { + logger.warn('Box service-account identity lookup threw', { + step: IDENTITY_STEP, + enterpriseId: orgId, + error: getErrorMessage(error), + }) + return degraded(providerFailureReason(error)) } } diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts index 6e0aad65f22..0b874321280 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts @@ -78,6 +78,7 @@ describe('mintSalesforceServiceAccountToken', () => { name: 'Integration User', preferred_username: 'integration@yourorg.com', organization_id: '00Dxx0000000001EAA', + user_id: '005xx000001Sv6DAAS', }) ) @@ -90,16 +91,19 @@ describe('mintSalesforceServiceAccountToken', () => { grantedScopes: ['api'], identity: { displayName: 'Integration User', + principal: { + kind: 'user', + id: '005xx000001Sv6DAAS', + label: 'integration@yourorg.com', + }, auditMetadata: { salesforceMyDomainHost: HOST, salesforceOrgId: '00Dxx0000000001EAA', - salesforceRunAsUsername: 'integration@yourorg.com', }, storedMetadata: { myDomainHost: HOST, instanceUrl: INSTANCE_URL, orgId: '00Dxx0000000001EAA', - runAsUsername: 'integration@yourorg.com', grantedScopes: 'api', }, }, @@ -259,7 +263,7 @@ describe('mintSalesforceServiceAccountToken', () => { }) }) - it('falls back to a host-derived identity when the userinfo call fails', async () => { + it('marks the principal as lookup_failed when the userinfo call throws', async () => { mockFetch .mockResolvedValueOnce( jsonResponse(200, { access_token: 'sf-access', instance_url: INSTANCE_URL }) @@ -271,11 +275,30 @@ describe('mintSalesforceServiceAccountToken', () => { expect(result.accessToken).toBe('sf-access') expect(result.identity).toEqual({ displayName: `Salesforce ${HOST}`, + principal: { kind: 'lookup_failed', reason: 'provider_unavailable (HTTP 502)' }, auditMetadata: { salesforceMyDomainHost: HOST }, storedMetadata: { myDomainHost: HOST, instanceUrl: INSTANCE_URL }, }) }) + it('marks the principal as lookup_failed when userinfo omits user_id', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'sf-access', instance_url: INSTANCE_URL }) + ) + .mockResolvedValueOnce(jsonResponse(200, { name: 'Integration User' })) + + const result = await mintSalesforceServiceAccountToken(FIELDS) + + expect(result.identity?.principal).toEqual({ + kind: 'lookup_failed', + reason: 'response missing user_id', + }) + // Only the principal degrades — a name that did come back still beats the + // host fallback, so the credential does not lose its label. + expect(result.identity?.displayName).toBe('Integration User') + }) + it('ignores a non-Salesforce instance_url and falls back to the validated host', async () => { mockFetch .mockResolvedValueOnce( diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts index 8928cf9d9c6..e8e702c98c7 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts @@ -1,3 +1,5 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { normalizeSalesforceMyDomainHost, SALESFORCE_MY_DOMAIN_HOST_REGEX, @@ -8,10 +10,12 @@ import type { ClientCredentialAccountMintOptions, ClientCredentialAccountMintResult, } from '@/lib/credentials/client-credential-accounts/server' +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, isTransientProviderStatus, parseProviderJson, + providerFailureReason, readProviderErrorSnippet, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' @@ -24,16 +28,29 @@ import { */ const SALESFORCE_TOKEN_TTL_SECONDS = 600 +const IDENTITY_STEP = 'salesforce_identity' + +const logger = createLogger('SalesforceServiceAccountMinter') + interface SalesforceTokenResponse { access_token?: string instance_url?: string scope?: string } +/** + * `/services/oauth2/userinfo` returns `user_id`, `organization_id`, + * `preferred_username`, and `name` in the same call the display name already + * needs, so capturing the run-as user id costs no extra request. `sub` is + * deliberately unused — Salesforce documents it as the UserInfo endpoint URL, + * not a subject identifier. + * @see https://help.salesforce.com/s/articleView?id=sf.remoteaccess_using_userinfo_endpoint.htm&type=5 + */ interface SalesforceUserinfoResponse { name?: string preferred_username?: string organization_id?: string + user_id?: string } /** @@ -91,27 +108,43 @@ function salesforceTokenTtlSeconds(accessToken: string): number { /** * Best-effort identity lookup for the run-as integration user via the - * standard userinfo endpoint. A failure never fails the mint — the caller - * falls back to a host-derived display name. + * standard userinfo endpoint. A failure never fails the mint — the credential + * degrades to a host-derived display name with a `lookup_failed` principal, so + * the audit record shows the identity was not captured rather than implying + * none exists. */ async function fetchSalesforceIdentity( accessToken: string, instanceUrl: string, host: string ): Promise { - const fallback: ClientCredentialAccountIdentity = { - displayName: `Salesforce ${host}`, + /** + * `label` keeps whatever human name userinfo did return. A response can carry + * `name`/`preferred_username` but no `user_id` — the principal is then + * unusable, but the label still beats the host fallback, so only the + * principal degrades and the credential does not silently lose its name. + */ + const degraded = (reason: string, label?: string): ClientCredentialAccountIdentity => ({ + displayName: label ?? `Salesforce ${host}`, + principal: { kind: 'lookup_failed', reason }, auditMetadata: { salesforceMyDomainHost: host }, storedMetadata: { myDomainHost: host, instanceUrl }, - } + }) try { const res = await fetchProvider( `${instanceUrl}/services/oauth2/userinfo`, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } }, - 'salesforce_identity' + IDENTITY_STEP ) - if (!res.ok) return fallback - const user = await parseProviderJson(res, 'salesforce_identity') + if (!res.ok) { + logger.warn('Salesforce run-as identity lookup failed', { + step: IDENTITY_STEP, + status: res.status, + host, + }) + return degraded(`HTTP ${res.status}`) + } + const user = await parseProviderJson(res, IDENTITY_STEP) const username = typeof user.preferred_username === 'string' && user.preferred_username ? user.preferred_username @@ -121,22 +154,37 @@ async function fetchSalesforceIdentity( typeof user.organization_id === 'string' && user.organization_id ? user.organization_id : undefined + const userId = typeof user.user_id === 'string' && user.user_id ? user.user_id : undefined + if (!userId) { + logger.warn('Salesforce userinfo response carried no user_id', { + step: IDENTITY_STEP, + status: res.status, + host, + }) + return degraded('response missing user_id', name ?? username) + } return { - displayName: name ?? username ?? fallback.displayName, + displayName: name ?? username ?? `Salesforce ${host}`, + // The 18-char user id is immutable; `preferred_username` is renameable, + // so it is only a label. + principal: userPrincipal(userId, username), auditMetadata: { salesforceMyDomainHost: host, ...(orgId ? { salesforceOrgId: orgId } : {}), - ...(username ? { salesforceRunAsUsername: username } : {}), }, storedMetadata: { myDomainHost: host, instanceUrl, ...(orgId ? { orgId } : {}), - ...(username ? { runAsUsername: username } : {}), }, } - } catch { - return fallback + } catch (error) { + logger.warn('Salesforce run-as identity lookup threw', { + step: IDENTITY_STEP, + host, + error: getErrorMessage(error), + }) + return degraded(providerFailureReason(error)) } } diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts index 839e452202a..6bf48fd65c1 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts @@ -130,12 +130,9 @@ describe('mintZohoDeskServiceAccountToken', () => { grantedScopes: ['Desk.tickets.READ', 'Desk.contacts.READ'], identity: { displayName: 'Zoho Desk org 600123456', - auditMetadata: { - zohoDeskSoid: 'ZohoDesk.600123456', - zohoDeskClientId: 'zoho-cid', - }, + principal: { kind: 'tenant', id: 'ZohoDesk.600123456' }, + auditMetadata: { zohoDeskClientId: 'zoho-cid' }, storedMetadata: { - soid: 'ZohoDesk.600123456', apiDomain: 'https://desk.zoho.com', dataCenter: 'us', grantedScopes: 'Desk.tickets.READ Desk.contacts.READ', diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts index 35f3f6f7963..7ceff1748a9 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts @@ -12,6 +12,7 @@ import type { ClientCredentialAccountMintOptions, ClientCredentialAccountMintResult, } from '@/lib/credentials/client-credential-accounts/server' +import { tenantPrincipal } from '@/lib/credentials/principal' import { fetchProvider, isTransientProviderStatus, @@ -255,7 +256,7 @@ export async function mintZohoDeskServiceAccountToken( return { accessToken: payload.access_token, expiresInSeconds, apiDomain, grantedScopes } } - const storedMetadata: Record = { soid, apiDomain, dataCenter: dataCenter.id } + const storedMetadata: Record = { apiDomain, dataCenter: dataCenter.id } if (grantedScopes?.length) { storedMetadata.grantedScopes = grantedScopes.join(' ') } @@ -267,7 +268,10 @@ export async function mintZohoDeskServiceAccountToken( grantedScopes, identity: { displayName: `Zoho Desk org ${fields.orgId.trim()}`, - auditMetadata: { zohoDeskSoid: soid, zohoDeskClientId: fields.clientId }, + // The Self Client grant is scoped to the organization (`soid`) and never + // hits the Accounts profile endpoint, so no agent identity exists here. + principal: tenantPrincipal(soid), + auditMetadata: { zohoDeskClientId: fields.clientId }, storedMetadata, }, } diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts index dcfd2822a14..afa900ed610 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts @@ -74,7 +74,8 @@ describe('mintZoomServiceAccountToken', () => { grantedScopes: ['meeting:read:meeting:admin', 'user:read:user:admin'], identity: { displayName: 'Zoom account AbCdEf123', - auditMetadata: { zoomAccountId: 'AbCdEf123', zoomClientId: 'zoom-cid' }, + principal: { kind: 'tenant', id: 'AbCdEf123' }, + auditMetadata: { zoomClientId: 'zoom-cid' }, storedMetadata: { apiUrl: 'https://api.zoom.us', grantedScopes: 'meeting:read:meeting:admin user:read:user:admin', diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts index 978409ae0ee..218eee37c1e 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts @@ -3,6 +3,7 @@ import type { ClientCredentialAccountMintOptions, ClientCredentialAccountMintResult, } from '@/lib/credentials/client-credential-accounts/server' +import { tenantPrincipal } from '@/lib/credentials/principal' import { fetchProvider, isTransientProviderStatus, @@ -124,7 +125,10 @@ export async function mintZoomServiceAccountToken( grantedScopes, identity: { displayName: `Zoom account ${fields.orgId}`, - auditMetadata: { zoomAccountId: fields.orgId, zoomClientId: fields.clientId }, + // A Server-to-Server app authenticates as the account, not as a Zoom + // user; the grant exposes no user identifier at all. + principal: tenantPrincipal(fields.orgId), + auditMetadata: { zoomClientId: fields.clientId }, ...(Object.keys(storedMetadata).length > 0 ? { storedMetadata } : {}), }, } diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index 44b216f8406..2a1f6bd2214 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -11,6 +11,7 @@ import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential- import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce' import { mintZohoDeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoho-desk' import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom' +import type { ServiceAccountPrincipal } from '@/lib/credentials/principal' /** Raw fields a client-credential minter receives (already trimmed). */ export interface ClientCredentialAccountFields { @@ -33,11 +34,21 @@ export interface ClientCredentialAccountFields { export interface ClientCredentialAccountIdentity { /** Default display name when the user didn't provide one. */ displayName: string - /** Non-secret identifiers recorded in the audit log (e.g. account/enterprise id). */ + /** + * Identity the minted token acts as, or `null` when the provider exposes + * none. Required (never optional) so a new minter cannot be written without + * deciding. `verifyAndBuildServiceAccountSecret` mirrors it into both + * `auditMetadata` and `storedMetadata`, so minters must not repeat it. + */ + principal: ServiceAccountPrincipal | null + /** + * Non-secret identifiers recorded in the audit log that are NOT the + * principal (e.g. the enterprise id behind a service-account user). + */ auditMetadata: Record /** * Non-secret metadata persisted inside the encrypted blob alongside the - * credentials (e.g. regional API host, service-account login) for debugging. + * credentials (e.g. regional API host, granted scopes) for debugging. */ storedMetadata?: Record } diff --git a/apps/sim/lib/credentials/display-name.ts b/apps/sim/lib/credentials/display-name.ts index 9b946f31e56..24f47acb279 100644 --- a/apps/sim/lib/credentials/display-name.ts +++ b/apps/sim/lib/credentials/display-name.ts @@ -47,3 +47,15 @@ export function defaultCredentialDisplayName( } return base } + +/** + * Display name for a custom Slack bot credential. + * + * Lives in this leaf module because two callers must derive it identically — + * the secret builder that sets it at connect time, and the update path that + * compares against it to tell a stale system-derived label from one a user + * typed. A copied literal would silently break that comparison. + */ +export function slackCustomBotDisplayName(teamName?: string | null): string { + return teamName || 'Slack bot' +} diff --git a/apps/sim/lib/credentials/environment.test.ts b/apps/sim/lib/credentials/environment.test.ts index 64c89d058d8..349a94d5db0 100644 --- a/apps/sim/lib/credentials/environment.test.ts +++ b/apps/sim/lib/credentials/environment.test.ts @@ -15,10 +15,93 @@ vi.mock('@/lib/billing/organizations/billing-identity-lock', () => ({ })) import { + getPersonalEnvKeyRawAccess, getWorkspaceEnvKeyAdminAccess, syncPersonalEnvCredentialsForUser, } from '@/lib/credentials/environment' +describe('getPersonalEnvKeyRawAccess', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns own values without querying credential grants', async () => { + const result = await getPersonalEnvKeyRawAccess({ + workspaceId: 'ws-1', + userId: 'u-1', + personalOwners: { OWN_KEY: 'u-1' }, + }) + + expect([...result.ownedKeys]).toEqual(['OWN_KEY']) + expect(result.adminKeys.size).toBe(0) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('allows own values and only active admin grants for other personal values', async () => { + queueTableRows(credential, [ + { + envKey: 'SHARED_ADMIN', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + }, + { + envKey: 'SHARED_MEMBER', + envOwnerUserId: 'owner-3', + role: 'member', + status: 'active', + }, + { + envKey: 'REVOKED_ADMIN', + envOwnerUserId: 'owner-4', + role: 'admin', + status: 'revoked', + }, + ]) + + const result = await getPersonalEnvKeyRawAccess({ + workspaceId: 'ws-1', + userId: 'u-1', + personalOwners: { + OWN_KEY: 'u-1', + SHARED_ADMIN: 'owner-2', + SHARED_MEMBER: 'owner-3', + REVOKED_ADMIN: 'owner-4', + }, + }) + + expect([...result.ownedKeys]).toEqual(['OWN_KEY']) + expect([...result.adminKeys]).toEqual(['SHARED_ADMIN']) + }) + + it('requires the admin grant to belong to the exact effective secret owner', async () => { + queueTableRows(credential, [ + { + envKey: 'COLLISION', + envOwnerUserId: 'owner-a', + role: 'admin', + status: 'active', + }, + { + envKey: 'COLLISION', + envOwnerUserId: 'owner-b', + role: 'member', + status: 'active', + }, + ]) + + const result = await getPersonalEnvKeyRawAccess({ + workspaceId: 'ws-1', + userId: 'u-1', + personalOwners: { COLLISION: 'owner-b' }, + }) + + expect(result.ownedKeys.size).toBe(0) + expect(result.adminKeys.size).toBe(0) + }) +}) + describe('getWorkspaceEnvKeyAdminAccess', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index da4f743cbab..e70be39ebff 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -107,6 +107,67 @@ export interface WorkspaceEnvKeyAdminAccess { knownKeys: Set } +export interface PersonalEnvKeyRawAccess { + /** Keys stored in the caller's own personal Secrets catalog. */ + ownedKeys: Set + /** Keys owned by someone else for which the caller is an active credential admin. */ + adminKeys: Set +} + +/** Resolves which personal secret values a workspace viewer may read as plaintext. */ +export async function getPersonalEnvKeyRawAccess(params: { + workspaceId: string + personalOwners: Record + userId: string +}): Promise { + const keys = Object.keys(params.personalOwners) + if (keys.length === 0) return { ownedKeys: new Set(), adminKeys: new Set() } + + const ownedKeys = new Set( + keys.filter((envKey) => params.personalOwners[envKey] === params.userId) + ) + const sharedKeys = keys.filter((envKey) => !ownedKeys.has(envKey)) + if (sharedKeys.length === 0) return { ownedKeys, adminKeys: new Set() } + + const credentialRows = await db + .select({ + envKey: credential.envKey, + envOwnerUserId: credential.envOwnerUserId, + role: credentialMember.role, + status: credentialMember.status, + }) + .from(credential) + .leftJoin( + credentialMember, + and( + eq(credentialMember.credentialId, credential.id), + eq(credentialMember.userId, params.userId) + ) + ) + .where( + and( + eq(credential.workspaceId, params.workspaceId), + eq(credential.type, 'env_personal'), + inArray(credential.envKey, sharedKeys) + ) + ) + + const adminKeys = new Set() + for (const row of credentialRows) { + if ( + row.envKey && + row.envOwnerUserId === params.personalOwners[row.envKey] && + row.envOwnerUserId !== params.userId && + row.role === 'admin' && + row.status === 'active' + ) { + adminKeys.add(row.envKey) + } + } + + return { ownedKeys, adminKeys } +} + /** * For a set of workspace env keys, resolves which the caller may administer * (active `credential_member` with role `admin`) and which already have an diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts new file mode 100644 index 00000000000..18e0cfb0d1d --- /dev/null +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -0,0 +1,298 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockRecordAudit, + mockGetCredentialActorContext, + mockDecryptSecret, + mockVerifyAndBuildServiceAccountSecret, + mockIsClientCredentialAccountProviderId, +} = vi.hoisted(() => ({ + mockRecordAudit: vi.fn(), + mockGetCredentialActorContext: vi.fn(), + mockDecryptSecret: vi.fn(), + mockVerifyAndBuildServiceAccountSecret: vi.fn(), + mockIsClientCredentialAccountProviderId: vi.fn(() => false), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { CREDENTIAL_UPDATED: 'credential.updated' }, + AuditResourceType: { CREDENTIAL: 'credential' }, + recordAudit: mockRecordAudit, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) +vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret })) +vi.mock('@/lib/credentials/service-account-secret', () => ({ + verifyAndBuildServiceAccountSecret: mockVerifyAndBuildServiceAccountSecret, + ServiceAccountSecretError: class ServiceAccountSecretError extends Error {}, +})) +vi.mock('@/lib/credentials/client-credential-accounts/descriptors', () => ({ + isClientCredentialAccountProviderId: mockIsClientCredentialAccountProviderId, +})) +vi.mock('@/lib/credentials/deletion', () => ({ deleteCredential: vi.fn() })) +vi.mock('@/lib/credentials/environment', () => ({ + deleteWorkspaceEnvCredentials: vi.fn(), + syncPersonalEnvCredentialsForUser: vi.fn(), +})) +vi.mock('@/lib/credentials/atlassian-service-account', () => ({ + AtlassianValidationError: class AtlassianValidationError extends Error {}, +})) +vi.mock('@/lib/credentials/token-service-accounts/errors', () => ({ + TokenServiceAccountValidationError: class TokenServiceAccountValidationError extends Error {}, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { performUpdateCredential } from '@/lib/credentials/orchestration' + +const OLD_EMAIL = 'old-sa@old-project.iam.gserviceaccount.com' +const NEW_EMAIL = 'new-sa@new-project.iam.gserviceaccount.com' + +const NEW_GOOGLE_KEY = JSON.stringify({ + type: 'service_account', + client_email: NEW_EMAIL, + private_key: 'pk', + project_id: 'new-project', +}) + +/** Points `getCredentialActorContext` at an admin-accessible credential row. */ +function mockCredential(overrides: Record = {}) { + mockGetCredentialActorContext.mockResolvedValue({ + credential: { + id: 'cred-1', + workspaceId: 'ws-1', + type: 'service_account', + providerId: 'google-service-account', + displayName: OLD_EMAIL, + ...overrides, + }, + hasWorkspaceAccess: true, + isAdmin: true, + }) +} + +/** Queues the stored (pre-rotation) secret blob for the orchestration's read. */ +function mockStoredBlob(blob: unknown) { + queueTableRows(schemaMock.credential, [{ key: 'stored-cipher' }]) + mockDecryptSecret.mockResolvedValue({ decrypted: JSON.stringify(blob) }) +} + +/** + * The `set(...)` payload of the credential UPDATE — always the first mutation, + * ahead of the Slack bot-user-id propagation to webhooks. + */ +function updatePayload(): Record { + const call = dbChainMockFns.set.mock.calls[0] + return (call?.[0] ?? {}) as Record +} + +/** The metadata recorded on the CREDENTIAL_UPDATED audit entry. */ +function auditMetadata(): Record { + const call = mockRecordAudit.mock.calls.at(-1) + return ((call?.[0] as { metadata?: Record })?.metadata ?? {}) as Record< + string, + unknown + > +} + +describe('performUpdateCredential — service-account secret rotation', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsClientCredentialAccountProviderId.mockReturnValue(false) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'google-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: NEW_EMAIL, + auditMetadata: { principalKind: 'user', principalId: NEW_EMAIL }, + }) + }) + + it('re-labels a Google credential whose name is still the previous key identity', async () => { + mockCredential() + mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + serviceAccountJson: NEW_GOOGLE_KEY, + }) + + expect(result.success).toBe(true) + expect(updatePayload().displayName).toBe(NEW_EMAIL) + expect(updatePayload().encryptedServiceAccountKey).toBe('new-cipher') + expect(result.updatedFields).toContain('displayName') + expect(result.previousDisplayName).toBe(OLD_EMAIL) + }) + + it('keeps a label the user typed instead of the derived identity', async () => { + mockCredential({ displayName: 'Prod billing exporter' }) + mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + serviceAccountJson: NEW_GOOGLE_KEY, + }) + + expect(result.success).toBe(true) + expect(updatePayload()).not.toHaveProperty('displayName') + expect(result.updatedFields).not.toContain('displayName') + }) + + it('lets an explicit displayName in the same request win over the derived one', async () => { + mockCredential() + mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + displayName: 'Renamed by admin', + serviceAccountJson: NEW_GOOGLE_KEY, + }) + + expect(updatePayload().displayName).toBe('Renamed by admin') + // The stored blob is never read when the caller already named the credential. + expect(mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('leaves the label alone when the stored blob carries no recoverable identity', async () => { + mockCredential({ providerId: 'atlassian-service-account', displayName: 'Acme Jira' }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'atlassian-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: 'Other Site', + auditMetadata: { atlassianCloudId: 'cloud-2' }, + }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + apiToken: 'tok', + domain: 'other.atlassian.net', + }) + + expect(updatePayload()).not.toHaveProperty('displayName') + expect(mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('re-labels a Slack custom bot that still carries its previous team name', async () => { + mockCredential({ providerId: 'slack-custom-bot', displayName: 'Old Team' }) + mockStoredBlob({ type: 'slack_custom_bot', teamName: 'Old Team', teamId: 'T1' }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'slack-custom-bot', + encryptedServiceAccountKey: 'new-cipher', + displayName: 'New Team', + auditMetadata: { slackTeamId: 'T2' }, + botUserId: 'U2', + }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + botToken: 'xoxb-new', + signingSecret: 'sig', + }) + + expect(updatePayload().displayName).toBe('New Team') + }) + + it('merges the rebuilt secret audit metadata into the CREDENTIAL_UPDATED entry', async () => { + mockCredential() + mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + serviceAccountJson: NEW_GOOGLE_KEY, + }) + + expect(auditMetadata()).toMatchObject({ + credentialType: 'service_account', + principalKind: 'user', + principalId: NEW_EMAIL, + }) + expect(auditMetadata().updatedFields).toEqual( + expect.arrayContaining(['displayName', 'encryptedServiceAccountKey']) + ) + }) + + it('never lets provider audit metadata shadow the orchestration keys', async () => { + mockCredential({ providerId: 'atlassian-service-account', displayName: 'Acme Jira' }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'atlassian-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: 'Acme Jira', + auditMetadata: { credentialType: 'spoofed', updatedFields: 'spoofed' }, + }) + + await performUpdateCredential({ credentialId: 'cred-1', userId: 'user-1', apiToken: 'tok' }) + + expect(auditMetadata().credentialType).toBe('service_account') + expect(auditMetadata().updatedFields).toEqual(['encryptedServiceAccountKey']) + }) + + it('omits secret audit metadata on a metadata-only update', async () => { + mockCredential() + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + description: 'Billing exports', + }) + + expect(mockVerifyAndBuildServiceAccountSecret).not.toHaveBeenCalled() + expect(auditMetadata()).toEqual({ + credentialType: 'service_account', + updatedFields: ['description'], + }) + }) + + it('carries the stored dataCenter forward for a client-credential reconnect', async () => { + mockCredential({ providerId: 'zoho-desk-service-account', displayName: 'Acme Desk' }) + mockIsClientCredentialAccountProviderId.mockReturnValue(true) + mockStoredBlob({ type: 'client_credential_account', dataCenter: 'eu' }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'zoho-desk-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: 'Acme Desk', + auditMetadata: { zohoOrgId: 'org-1' }, + }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + clientId: 'cid', + clientSecret: 'csec', + orgId: 'org-1', + }) + + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'zoho-desk-service-account', + expect.objectContaining({ dataCenter: 'eu' }) + ) + }) + + it('surfaces a rebuild failure as a validation error and writes nothing', async () => { + mockCredential() + mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) + const { ServiceAccountSecretError } = await import('@/lib/credentials/service-account-secret') + mockVerifyAndBuildServiceAccountSecret.mockRejectedValue( + new ServiceAccountSecretError('Invalid service account JSON') + ) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + serviceAccountJson: '{}', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index ea69218e38c..b36ca844047 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -5,11 +5,12 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { decryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors' import { type CredentialDeleteReason, deleteCredential } from '@/lib/credentials/deletion' +import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' import { deleteWorkspaceEnvCredentials, syncPersonalEnvCredentialsForUser, @@ -19,18 +20,40 @@ import { verifyAndBuildServiceAccountSecret, } from '@/lib/credentials/service-account-secret' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + SLACK_CUSTOM_BOT_PROVIDER_ID, + SLACK_CUSTOM_BOT_SECRET_TYPE, +} from '@/lib/oauth/types' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') /** - * Read the `dataCenter` already stored in a service-account credential's - * encrypted blob. Used on reconnect so a non-secret regional selector survives a - * secret rotation that does not resubmit it. Returns undefined on any failure - - * a blob that cannot be read must not block the reconnect, and the provider's - * own default then applies. + * Google's stored blob is the raw GCP JSON key, whose own `type` discriminator + * is `service_account`. + */ +const GOOGLE_SERVICE_ACCOUNT_KEY_TYPE = 'service_account' + +/** + * Provider ids whose credential `displayName` is derived from the secret's own + * principal at create time AND whose principal is recoverable from the stored + * blob. Only for these can a reconnect tell a stale derived label apart from a + * name the user typed. An empty provider id is a legacy Google service account + * (the original flow predates multi-provider support). + */ +const IDENTITY_DERIVED_DISPLAY_NAME_PROVIDERS: ReadonlySet = new Set([ + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + SLACK_CUSTOM_BOT_PROVIDER_ID, + '', +]) + +/** + * Read and decrypt a service-account credential's stored secret blob. Returns + * null on any failure - a blob that cannot be read must never block a + * reconnect; each caller degrades to the behaviour it had without the blob. */ -async function readStoredDataCenter(credentialId: string): Promise { +async function readStoredSecretBlob(credentialId: string): Promise | null> { try { const rows = await db .select({ key: credential.encryptedServiceAccountKey }) @@ -38,13 +61,41 @@ async function readStoredDataCenter(credentialId: string): Promise) : null } catch { - return undefined + return null + } +} + +/** + * The `dataCenter` already stored in a service-account blob. Used on reconnect + * so a non-secret regional selector survives a secret rotation that does not + * resubmit it; undefined lets the provider's own default apply. + */ +function readStoredDataCenter(blob: Record | null): string | undefined { + const dataCenter = blob?.dataCenter + return typeof dataCenter === 'string' && dataCenter ? dataCenter : undefined +} + +/** + * Recompute the display name that `verifyAndBuildServiceAccountSecret` derived + * from the *stored* secret, so a reconnect can tell whether the current label + * is still the previous principal or a name the user deliberately typed. + * Returns undefined when the blob does not carry its own identity, in which + * case the label must be left alone. + */ +function deriveStoredDisplayName(blob: Record | null): string | undefined { + if (!blob) return undefined + if (blob.type === SLACK_CUSTOM_BOT_SECRET_TYPE) { + return slackCustomBotDisplayName(typeof blob.teamName === 'string' ? blob.teamName : undefined) } + if (blob.type === GOOGLE_SERVICE_ACCOUNT_KEY_TYPE && typeof blob.client_email === 'string') { + return blob.client_email || undefined + } + return undefined } export type CredentialOrchestrationErrorCode = @@ -125,34 +176,13 @@ export async function performUpdateCredential( ) { updates.displayName = params.displayName } - if (params.serviceAccountJson !== undefined && access.credential.type === 'service_account') { - let parsedJson: Record - try { - parsedJson = JSON.parse(params.serviceAccountJson) - } catch { - return { success: false, error: 'Invalid JSON format', errorCode: 'validation' } - } - if ( - parsedJson.type !== 'service_account' || - typeof parsedJson.client_email !== 'string' || - typeof parsedJson.private_key !== 'string' || - typeof parsedJson.project_id !== 'string' - ) { - return { - success: false, - error: 'Invalid service account JSON key', - errorCode: 'validation', - } - } - const { encrypted } = await encryptSecret(params.serviceAccountJson) - updates.encryptedServiceAccountKey = encrypted - } - - // Reconnect: rotate a service-account secret (Slack, Atlassian, or any - // token-paste provider) in place. The - // secret is re-verified against the provider and re-encrypted; the display - // name is preserved (the user may have renamed it). + // Reconnect: rotate a service-account secret (Google JSON key, Slack, + // Atlassian, or any token-paste / client-credential provider) in place. The + // secret is re-verified against the provider and re-encrypted through the + // same builder the create path uses, so the rotation also yields the new + // principal's derived display name and audit metadata. const hasRotationSecret = + params.serviceAccountJson !== undefined || params.signingSecret !== undefined || params.botToken !== undefined || params.apiToken !== undefined || @@ -162,38 +192,61 @@ export async function performUpdateCredential( params.orgId !== undefined || params.dataCenter !== undefined let rotatedSlackBotUserId: string | undefined + let rotatedAuditMetadata: Record | undefined if (hasRotationSecret && access.credential.type === 'service_account') { + const providerId = access.credential.providerId ?? '' + // A reconnect rebuilds the secret blob from the submitted fields only, and // the modal never prefills (secrets are never echoed back). For an actual // secret that is correct - the admin retypes it. But a non-secret selector // like the Zoho data center would be silently dropped, moving an EU/IN/AU // credential back to the US accounts server. Carry the stored value forward // when the caller did not supply one. - // Scoped to the providers that actually have a dataCenter field, so no - // other service-account reconnect (Slack, Atlassian, every token-paste - // provider) pays for a DB read plus a decrypt it can never use. - const carriedDataCenter = - params.dataCenter === undefined && - isClientCredentialAccountProviderId(access.credential.providerId ?? '') - ? await readStoredDataCenter(access.credential.id) - : params.dataCenter + const needsStoredDataCenter = + params.dataCenter === undefined && isClientCredentialAccountProviderId(providerId) + + // Rotating to a key that belongs to a different principal makes an + // identity-derived label (a Google `client_email`, a Slack team name) + // actively wrong about who the credential authenticates as. Re-derive it - + // but only when the stored label is still the previous principal, so a + // name the user deliberately typed always wins. An explicit `displayName` + // in this same request wins outright and skips the read entirely. + const needsStoredIdentity = + params.displayName === undefined && IDENTITY_DERIVED_DISPLAY_NAME_PROVIDERS.has(providerId) + + // One read + decrypt at most, and only for the providers that can use it. + const storedBlob = + needsStoredDataCenter || needsStoredIdentity + ? await readStoredSecretBlob(access.credential.id) + : null try { - const secret = await verifyAndBuildServiceAccountSecret( - access.credential.providerId ?? '', - { - signingSecret: params.signingSecret, - botToken: params.botToken, - apiToken: params.apiToken, - domain: params.domain, - clientId: params.clientId, - clientSecret: params.clientSecret, - orgId: params.orgId, - dataCenter: carriedDataCenter, - } - ) + const secret = await verifyAndBuildServiceAccountSecret(providerId, { + signingSecret: params.signingSecret, + botToken: params.botToken, + apiToken: params.apiToken, + domain: params.domain, + serviceAccountJson: params.serviceAccountJson, + clientId: params.clientId, + clientSecret: params.clientSecret, + orgId: params.orgId, + dataCenter: needsStoredDataCenter ? readStoredDataCenter(storedBlob) : params.dataCenter, + }) updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey rotatedSlackBotUserId = secret.botUserId + rotatedAuditMetadata = secret.auditMetadata + + if (needsStoredIdentity) { + const previousIdentity = deriveStoredDisplayName(storedBlob) + if ( + previousIdentity !== undefined && + previousIdentity === access.credential.displayName && + secret.displayName && + secret.displayName !== previousIdentity + ) { + updates.displayName = secret.displayName + } + } } catch (error) { if (error instanceof ServiceAccountSecretError) { return { success: false, error: error.message, errorCode: 'validation' } @@ -260,7 +313,10 @@ export async function performUpdateCredential( resourceId: params.credentialId, resourceName: access.credential.displayName, description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, + // Provider metadata first: the orchestration's own keys stay authoritative + // and can never be shadowed by a builder's audit payload. metadata: { + ...rotatedAuditMetadata, credentialType: access.credential.type, updatedFields, }, diff --git a/apps/sim/lib/credentials/principal.ts b/apps/sim/lib/credentials/principal.ts new file mode 100644 index 00000000000..3339b00ec1a --- /dev/null +++ b/apps/sim/lib/credentials/principal.ts @@ -0,0 +1,62 @@ +/** + * Provider-identity primitives for service-account credentials. + * + * Deliberately a leaf module: the token and client-credential registries both + * need these, and `service-account-secret` imports values from both registries. + * Defining them there would close a runtime import cycle. + */ + +/** + * Provider identity captured while verifying a service-account credential. + * + * `tenant` exists because several providers can only ever report an + * org/workspace/site-level identifier (Attio, Shopify, Webflow, Zoom, Zoho + * Desk) — callers must never present those as the human actor behind the + * credential. `lookup_failed` records that the provider does expose a + * principal but the lookup did not complete, which is distinct from a + * provider that exposes no principal at all (`null`). + */ +export type ServiceAccountPrincipal = + | { kind: 'user'; id: string; label?: string } + | { kind: 'tenant'; id: string; label?: string } + | { kind: 'lookup_failed'; reason: string } + +/** + * The human actor a credential authenticates as. + * + * `label` accepts null/undefined because provider payloads routinely type an + * optional email or username that way, and is dropped when empty so + * {@link serviceAccountPrincipalMetadata} never emits a blank key. + */ +export function userPrincipal(id: string, label?: string | null): ServiceAccountPrincipal { + return { kind: 'user', id, ...(label ? { label } : {}) } +} + +/** + * An org/workspace/site-level identifier, for the providers that expose no + * actor at all. Kept distinct from {@link userPrincipal} so callers can never + * present a tenant id as the person behind the credential. + */ +export function tenantPrincipal(id: string, label?: string | null): ServiceAccountPrincipal { + return { kind: 'tenant', id, ...(label ? { label } : {}) } +} + +/** + * Flattens a principal into the string map mirrored into both `auditMetadata` + * (queryable on `audit_log.metadata`) and `storedMetadata` (inside the + * encrypted blob). Applied centrally by the builders below so no provider can + * capture a principal and forget to surface it. + */ +export function serviceAccountPrincipalMetadata( + principal: ServiceAccountPrincipal | null +): Record { + if (principal === null) return { principalKind: 'none' } + if (principal.kind === 'lookup_failed') { + return { principalKind: 'lookup_failed', principalLookupError: principal.reason } + } + return { + principalKind: principal.kind, + principalId: principal.id, + ...(principal.label ? { principalLabel: principal.label } : {}), + } +} diff --git a/apps/sim/lib/credentials/secret-mount-options.test.ts b/apps/sim/lib/credentials/secret-mount-options.test.ts new file mode 100644 index 00000000000..ae688715dbc --- /dev/null +++ b/apps/sim/lib/credentials/secret-mount-options.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import type { WorkspaceCredential } from '@/lib/api/contracts' +import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' + +function credential( + overrides: Partial & Pick +): WorkspaceCredential { + return { + workspaceId: 'workspace-1', + displayName: overrides.id, + description: null, + providerId: null, + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + ...overrides, + } +} + +describe('selectRawMountableSecretNames', () => { + it('keeps only admin environment credentials and returns unique sorted names', () => { + const credentials = [ + credential({ id: 'workspace-z', type: 'env_workspace', envKey: 'ZETA', role: 'admin' }), + credential({ id: 'personal-a', type: 'env_personal', envKey: 'ALPHA', role: 'admin' }), + credential({ id: 'duplicate-a', type: 'env_workspace', envKey: 'ALPHA', role: 'admin' }), + credential({ id: 'member', type: 'env_workspace', envKey: 'MEMBER', role: 'member' }), + credential({ id: 'oauth', type: 'oauth', envKey: 'OAUTH', role: 'admin' }), + credential({ id: 'missing-key', type: 'env_personal', envKey: null, role: 'admin' }), + ] + + expect(selectRawMountableSecretNames(credentials)).toEqual(['ALPHA', 'ZETA']) + }) +}) diff --git a/apps/sim/lib/credentials/secret-mount-options.ts b/apps/sim/lib/credentials/secret-mount-options.ts new file mode 100644 index 00000000000..d159601ac69 --- /dev/null +++ b/apps/sim/lib/credentials/secret-mount-options.ts @@ -0,0 +1,22 @@ +import type { WorkspaceCredential } from '@/lib/api/contracts' + +/** + * Returns the secret names the current credential-list actor may mount as plaintext. + * The credentials API has already derived workspace-admin and per-credential roles; + * this selector deliberately keeps only environment credentials with effective admin access. + */ +export function selectRawMountableSecretNames(credentials: WorkspaceCredential[]): string[] { + const names = new Set() + + for (const credential of credentials) { + if ( + (credential.type === 'env_workspace' || credential.type === 'env_personal') && + credential.role === 'admin' && + credential.envKey + ) { + names.add(credential.envKey) + } + } + + return [...names].sort() +} diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index 27fa472f15f..b874432a680 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -104,6 +104,7 @@ describe('verifyAndBuildServiceAccountSecret', () => { accountId: 'acc-1', displayName: 'Jira Bot', cloudId: 'cloud-1', + emailAddress: 'bot@acme.com', }) const result = await verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, { apiToken: 'tok', @@ -112,6 +113,9 @@ describe('verifyAndBuildServiceAccountSecret', () => { expect(result.providerId).toBe(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) expect(result.displayName).toBe('Jira Bot') expect(result.auditMetadata.atlassianCloudId).toBe('cloud-1') + expect(result.principal).toEqual({ kind: 'user', id: 'acc-1', label: 'bot@acme.com' }) + expect(result.auditMetadata.principalId).toBe('acc-1') + expect(result.auditMetadata.principalLabel).toBe('bot@acme.com') const blob = JSON.parse(result.encryptedServiceAccountKey) expect(blob).toMatchObject({ apiToken: 'tok', @@ -127,17 +131,32 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) it('validates and encrypts a Google service-account JSON key', async () => { - const json = JSON.stringify({ type: 'service_account', client_email: 'svc@proj.iam' }) + const json = JSON.stringify({ + type: 'service_account', + client_email: 'svc@proj.iam', + project_id: 'proj', + }) const result = await verifyAndBuildServiceAccountSecret('google-service-account', { serviceAccountJson: json, }) expect(result.providerId).toBe('google-service-account') expect(result.displayName).toBe('svc@proj.iam') expect(result.encryptedServiceAccountKey).toBe(json) + expect(result.principal).toEqual({ kind: 'user', id: 'svc@proj.iam' }) + expect(result.auditMetadata).toEqual({ + googleClientEmail: 'svc@proj.iam', + googleProjectId: 'proj', + principalKind: 'user', + principalId: 'svc@proj.iam', + }) }) it('accepts a legacy Google create with an empty providerId', async () => { - const json = JSON.stringify({ type: 'service_account', client_email: 'svc@proj.iam' }) + const json = JSON.stringify({ + type: 'service_account', + client_email: 'svc@proj.iam', + project_id: 'proj', + }) const result = await verifyAndBuildServiceAccountSecret('', { serviceAccountJson: json }) expect(result.providerId).toBe('google-service-account') }) @@ -157,6 +176,7 @@ describe('verifyAndBuildServiceAccountSecret', () => { expiresInSeconds: 3600, identity: { displayName: 'Zoom account acc-1', + principal: { kind: 'tenant', id: 'acc-1' }, auditMetadata: { zoomAccountId: 'acc-1' }, storedMetadata: { apiUrl: 'https://api.zoom.us' }, }, @@ -168,7 +188,11 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) expect(result.providerId).toBe('zoom-service-account') expect(result.displayName).toBe('Zoom account acc-1') - expect(result.auditMetadata).toEqual({ zoomAccountId: 'acc-1' }) + expect(result.auditMetadata).toEqual({ + zoomAccountId: 'acc-1', + principalKind: 'tenant', + principalId: 'acc-1', + }) expect(mockClientCredentialMinter).toHaveBeenCalledWith({ clientId: 'cid', clientSecret: 'csec', @@ -181,7 +205,11 @@ describe('verifyAndBuildServiceAccountSecret', () => { clientId: 'cid', clientSecret: 'csec', orgId: 'acc-1', - metadata: { apiUrl: 'https://api.zoom.us' }, + metadata: { + apiUrl: 'https://api.zoom.us', + principalKind: 'tenant', + principalId: 'acc-1', + }, }) }) @@ -193,9 +221,10 @@ describe('verifyAndBuildServiceAccountSecret', () => { orgId: '999', }) expect(result.displayName).toBe('Box 999') - expect(result.auditMetadata).toEqual({}) + expect(result.principal).toBeNull() + expect(result.auditMetadata).toEqual({ principalKind: 'none' }) const blob = JSON.parse(result.encryptedServiceAccountKey) - expect(blob.metadata).toBeUndefined() + expect(blob.metadata).toEqual({ principalKind: 'none' }) }) it('throws when client-credential required fields are missing, without minting', async () => { diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 900462b6e27..d6b678d4a17 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -15,6 +15,11 @@ import { type ClientCredentialAccountSecretBlob, getClientCredentialAccountMinter, } from '@/lib/credentials/client-credential-accounts/server' +import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' +import { + type ServiceAccountPrincipal, + serviceAccountPrincipalMetadata, +} from '@/lib/credentials/principal' import { getTokenServiceAccountDescriptor, isTokenServiceAccountProviderId, @@ -52,6 +57,12 @@ export interface ServiceAccountSecretResult { encryptedServiceAccountKey: string displayName: string auditMetadata: Record + /** + * Provider principal behind the credential, or `null` when the provider + * exposes none. Required (never optional) so a new provider cannot be added + * without deciding what identity it captures. + */ + principal: ServiceAccountPrincipal | null /** Slack custom bot: the derived bot user id (for reaction self-drop). */ botUserId?: string } @@ -78,12 +89,20 @@ async function buildAtlassianServiceAccountSecret( } const normalizedDomain = normalizeAtlassianDomain(domain) const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain) + const principal: ServiceAccountPrincipal = { + kind: 'user', + id: validation.accountId, + ...(validation.emailAddress ? { label: validation.emailAddress } : {}), + } + // `atlassianAccountId` stays at the blob's top level: `getAtlassianServiceAccountSecret` + // in `app/api/auth/oauth/utils.ts` reads it there on every existing credential. const blob = JSON.stringify({ type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, apiToken, domain: normalizedDomain, cloudId: validation.cloudId, atlassianAccountId: validation.accountId, + metadata: serviceAccountPrincipalMetadata(principal), }) const { encrypted } = await encryptSecret(blob) return { @@ -93,7 +112,9 @@ async function buildAtlassianServiceAccountSecret( auditMetadata: { atlassianDomain: normalizedDomain, atlassianCloudId: validation.cloudId, + ...serviceAccountPrincipalMetadata(principal), }, + principal, } } @@ -123,6 +144,11 @@ async function buildSlackCustomBotSecret( `Could not verify the Slack bot token: ${getErrorMessage(error)}` ) } + // `auth.test` returns the bot user only for bot tokens; a token without one + // is workspace-scoped, so the team is the finest identity available. + const principal: ServiceAccountPrincipal = botUserId + ? { kind: 'user', id: botUserId } + : { kind: 'tenant', id: teamId, ...(teamName ? { label: teamName } : {}) } const blob = JSON.stringify({ type: SLACK_CUSTOM_BOT_SECRET_TYPE, signingSecret, @@ -130,13 +156,15 @@ async function buildSlackCustomBotSecret( teamId, botUserId, teamName, + metadata: serviceAccountPrincipalMetadata(principal), }) const { encrypted } = await encryptSecret(blob) return { providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, encryptedServiceAccountKey: encrypted, - displayName: teamName || 'Slack bot', - auditMetadata: { slackTeamId: teamId }, + displayName: slackCustomBotDisplayName(teamName), + auditMetadata: { slackTeamId: teamId, ...serviceAccountPrincipalMetadata(principal) }, + principal, botUserId, } } @@ -161,12 +189,23 @@ async function buildGoogleServiceAccountSecret( getValidationErrorMessage(jsonParseResult.error, 'Invalid service account JSON') ) } + const { client_email: clientEmail, project_id: projectId } = jsonParseResult.data + // `client_email` is the principal a Google service account authenticates as + // (its `unique_id` is not guaranteed to be present in a downloaded key). + const principal: ServiceAccountPrincipal = { kind: 'user', id: clientEmail } + // The blob stays the verbatim GCP key — every consumer parses it as one — so + // the principal is mirrored into the audit metadata only. const { encrypted } = await encryptSecret(serviceAccountJson) return { providerId: GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, encryptedServiceAccountKey: encrypted, - displayName: jsonParseResult.data.client_email, - auditMetadata: {}, + displayName: clientEmail, + auditMetadata: { + googleClientEmail: clientEmail, + googleProjectId: projectId, + ...serviceAccountPrincipalMetadata(principal), + }, + principal, } } @@ -197,19 +236,21 @@ async function buildTokenServiceAccountSecret( ) } const validation = await validator({ apiToken, domain }) + const principalMetadata = serviceAccountPrincipalMetadata(validation.principal) const blob: TokenServiceAccountSecretBlob = { type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, providerId, apiToken, ...(requiresDomain ? { domain: validation.normalizedDomain ?? domain } : {}), - ...(validation.storedMetadata ? { metadata: validation.storedMetadata } : {}), + metadata: { ...validation.storedMetadata, ...principalMetadata }, } const { encrypted } = await encryptSecret(JSON.stringify(blob)) return { providerId, encryptedServiceAccountKey: encrypted, displayName: validation.displayName, - auditMetadata: validation.auditMetadata, + auditMetadata: { ...validation.auditMetadata, ...principalMetadata }, + principal: validation.principal, } } @@ -246,6 +287,10 @@ async function buildClientCredentialAccountSecret( ) } const mint = await minter({ clientId, clientSecret, orgId, dataCenter }) + // `identity` is absent only on the `skipIdentity` execution-time path, which + // never reaches this builder; treat it as "no principal captured". + const principal = mint.identity?.principal ?? null + const principalMetadata = serviceAccountPrincipalMetadata(principal) const blob: ClientCredentialAccountSecretBlob = { type: CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, providerId, @@ -253,14 +298,15 @@ async function buildClientCredentialAccountSecret( clientSecret, orgId, ...(dataCenter ? { dataCenter } : {}), - ...(mint.identity?.storedMetadata ? { metadata: mint.identity.storedMetadata } : {}), + metadata: { ...mint.identity?.storedMetadata, ...principalMetadata }, } const { encrypted } = await encryptSecret(JSON.stringify(blob)) return { providerId, encryptedServiceAccountKey: encrypted, displayName: mint.identity?.displayName ?? `${descriptor.serviceLabel} ${orgId}`, - auditMetadata: mint.identity?.auditMetadata ?? {}, + auditMetadata: { ...mint.identity?.auditMetadata, ...principalMetadata }, + principal, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts index abf0b003b96..3e6ec4cbed6 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -1,3 +1,4 @@ +import { getErrorMessage } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' /** @@ -23,6 +24,19 @@ export class TokenServiceAccountValidationError extends Error { const ERROR_SNIPPET_MAX_LENGTH = 500 +/** + * Short, stable description of a failed best-effort provider call, for callers + * that degrade instead of throwing. `TokenServiceAccountValidationError`'s + * message is only its code, so the status is appended to keep the reason + * diagnosable. + */ +export function providerFailureReason(error: unknown): string { + if (error instanceof TokenServiceAccountValidationError) { + return `${error.code} (HTTP ${error.status})` + } + return getErrorMessage(error, 'request failed') +} + /** * Transient statuses a provider token/verification endpoint can return that * say nothing about the submitted credentials (throttling, request timeout) — diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index a7a693b1ca0..4fee7e16e40 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -1,3 +1,4 @@ +import type { ServiceAccountPrincipal } from '@/lib/credentials/principal' import { AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID, ASANA_SERVICE_ACCOUNT_PROVIDER_ID, @@ -44,11 +45,21 @@ export interface TokenServiceAccountFields { export interface TokenServiceAccountValidationResult { /** Default display name when the user didn't provide one. */ displayName: string - /** Non-secret identifiers recorded in the audit log (e.g. portal/workspace id). */ + /** + * Identity the token authenticates as, or `null` when the provider exposes + * none. Required (never optional) so a new validator cannot be written + * without deciding. `verifyAndBuildServiceAccountSecret` mirrors it into both + * `auditMetadata` and `storedMetadata`, so validators must not repeat it. + */ + principal: ServiceAccountPrincipal | null + /** + * Non-secret identifiers recorded in the audit log that are NOT the + * principal (e.g. the org id behind a user principal). + */ auditMetadata: Record /** * Non-secret metadata persisted inside the encrypted blob alongside the - * token (e.g. normalized store domain, portal id) for later debugging. + * token (e.g. normalized store domain, granted scopes) for later debugging. */ storedMetadata?: Record /** Normalized domain to persist instead of the raw user input (when collected). */ diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts index b1f4797b509..b71becb916b 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts @@ -37,8 +37,9 @@ describe('validateAirtableServiceAccount', () => { expect(result).toEqual({ displayName: 'svc@example.com', - auditMetadata: { airtableUserId: 'usrABC123' }, - storedMetadata: { userId: 'usrABC123', scopes: 'data.records:read' }, + principal: { kind: 'user', id: 'usrABC123', label: 'svc@example.com' }, + auditMetadata: {}, + storedMetadata: { scopes: 'data.records:read' }, }) expect(mockFetch).toHaveBeenCalledWith('https://api.airtable.com/v0/meta/whoami', { headers: { @@ -55,8 +56,9 @@ describe('validateAirtableServiceAccount', () => { const result = await validateAirtableServiceAccount({ apiToken: 'pat456.secret' }) expect(result.displayName).toBe('Airtable user usrXYZ789') - expect(result.auditMetadata).toEqual({ airtableUserId: 'usrXYZ789' }) - expect(result.storedMetadata).toEqual({ userId: 'usrXYZ789' }) + expect(result.principal).toEqual({ kind: 'user', id: 'usrXYZ789' }) + expect(result.auditMetadata).toEqual({}) + expect(result.storedMetadata).toEqual({}) }) it('throws invalid_credentials on 401', async () => { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts index ccab65691eb..c70ed3c4b1b 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -47,14 +48,15 @@ export async function validateAirtableServiceAccount( }) } - const storedMetadata: Record = { userId: whoami.id } + const storedMetadata: Record = {} if (whoami.scopes) { storedMetadata.scopes = whoami.scopes.join(' ') } return { displayName: whoami.email ?? `Airtable user ${whoami.id}`, - auditMetadata: { airtableUserId: whoami.id }, + principal: userPrincipal(whoami.id, whoami.email), + auditMetadata: {}, storedMetadata, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts index 6e950a55002..c1c57c7f815 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts @@ -35,8 +35,8 @@ describe('validateAsanaServiceAccount', () => { expect(result).toEqual({ displayName: 'Sim Integration', - auditMetadata: { asanaUserGid: '12345' }, - storedMetadata: { userGid: '12345', email: 'bot@example.com' }, + principal: { kind: 'user', id: '12345', label: 'bot@example.com' }, + auditMetadata: {}, }) expect(mockFetch).toHaveBeenCalledWith( 'https://app.asana.com/api/1.0/users/me?opt_fields=gid,name,email', @@ -62,7 +62,7 @@ describe('validateAsanaServiceAccount', () => { const gidOnly = await validateAsanaServiceAccount({ apiToken: 'token-2' }) expect(gidOnly.displayName).toBe('Asana user 999') - expect(gidOnly.storedMetadata).toEqual({ userGid: '999' }) + expect(gidOnly.principal).toEqual({ kind: 'user', id: '999' }) }) it('maps 401 to invalid_credentials', async () => { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts index c138258ee35..e35f0adff37 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -51,12 +52,10 @@ export async function validateAsanaServiceAccount( const name = body.data?.name const email = body.data?.email - const storedMetadata: Record = { userGid: gid } - if (email) storedMetadata.email = email return { displayName: name || email || `Asana user ${gid}`, - auditMetadata: { asanaUserGid: gid }, - storedMetadata, + principal: userPrincipal(gid, email), + auditMetadata: {}, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts index 7c193f063e5..92002f1752c 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts @@ -60,8 +60,8 @@ describe('validateAttioServiceAccount', () => { }) expect(result).toEqual({ displayName: 'Acme CRM', - auditMetadata: { attioWorkspaceId: 'ws-123' }, - storedMetadata: { workspaceId: 'ws-123', workspaceSlug: 'acme-crm' }, + principal: { kind: 'tenant', id: 'ws-123', label: 'acme-crm' }, + auditMetadata: {}, }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts index 1969439c4ac..c0b7ccc1d0d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts @@ -69,14 +69,15 @@ export async function validateAttioServiceAccount( }) } - const storedMetadata: Record = { workspaceId: self.workspace_id } - if (self.workspace_slug) { - storedMetadata.workspaceSlug = self.workspace_slug - } - + // An Attio workspace access token is not bound to a member, so the workspace + // is the finest identity the token can ever report. return { displayName: self.workspace_name || 'Attio workspace', - auditMetadata: { attioWorkspaceId: self.workspace_id }, - storedMetadata, + principal: { + kind: 'tenant', + id: self.workspace_id, + ...(self.workspace_slug ? { label: self.workspace_slug } : {}), + }, + auditMetadata: {}, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts index cbd22a5b1a2..78b0b7bbcd2 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts @@ -36,8 +36,8 @@ describe('validateCalcomServiceAccount', () => { expect(result).toEqual({ displayName: 'sim-bot', - auditMetadata: { calcomUserId: '42' }, - storedMetadata: { userId: '42', email: 'bot@example.com' }, + principal: { kind: 'user', id: '42', label: 'sim-bot' }, + auditMetadata: {}, }) expect(mockFetch).toHaveBeenCalledWith('https://api.cal.com/v2/me', { headers: { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts index c536bb43b1e..bd8cc59ab07 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -54,12 +55,11 @@ export async function validateCalcomServiceAccount( const userId = String(body.data.id) const username = body.data.username const email = body.data.email - const storedMetadata: Record = { userId } - if (email) storedMetadata.email = email + const label = username || email return { displayName: username || email || 'Cal.com account', - auditMetadata: { calcomUserId: userId }, - storedMetadata, + principal: userPrincipal(userId, label), + auditMetadata: {}, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts b/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts index f457d6be29d..566834f4fd2 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts @@ -35,8 +35,13 @@ export async function validateClaudePlatformServiceAccount( await throwForProviderResponse(res, 'agents_list') const suffix = fields.apiToken.slice(-4) + // Explicitly no principal: the Managed Agents API exposes no whoami endpoint + // and no workspace identifier on any response, so nothing about the key's + // owner is knowable at connect time. This is a provider limitation, not a + // failed lookup — see `ServiceAccountPrincipal`. return { displayName: `Claude Platform (…${suffix})`, + principal: null, auditMetadata: {}, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts b/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts index 129fbb1fb02..6facd5d4b28 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -65,9 +66,11 @@ export async function validateClickupServiceAccount( }) } + const label = user.username || user.email + return { displayName: user.username || user.email || 'ClickUp account', - auditMetadata: { clickupUserId: String(user.id) }, - storedMetadata: { userId: String(user.id) }, + principal: userPrincipal(String(user.id), label), + auditMetadata: {}, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts index 1259b11ac0a..c37e2ad1a53 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts @@ -74,8 +74,9 @@ describe('validateHubspotServiceAccount', () => { expect(result).toEqual({ displayName: 'HubSpot portal 12345', + principal: { kind: 'user', id: '111' }, auditMetadata: { hubspotHubId: '12345' }, - storedMetadata: { hubId: '12345', appId: '222', userId: '111' }, + storedMetadata: { hubId: '12345', appId: '222' }, }) expect(mockFetch).toHaveBeenCalledTimes(1) @@ -91,8 +92,8 @@ describe('validateHubspotServiceAccount', () => { expect(result).toEqual({ displayName: 'HubSpot portal 123', - auditMetadata: { hubspotHubId: '123' }, - storedMetadata: { hubId: '123' }, + principal: { kind: 'tenant', id: '123' }, + auditMetadata: {}, }) expect(mockFetch).toHaveBeenCalledTimes(2) @@ -127,8 +128,8 @@ describe('validateHubspotServiceAccount', () => { expect(result).toEqual({ displayName: 'HubSpot private app', + principal: null, auditMetadata: {}, - storedMetadata: {}, }) expect(mockFetch).toHaveBeenCalledTimes(2) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts index 457710e32c8..c22452e5b55 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts @@ -1,3 +1,4 @@ +import { tenantPrincipal, userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -41,10 +42,12 @@ async function verifyViaAccountInfo( 'account_info' ) if (res.status === 403) { + // The token is live but the app cannot read account info, so neither the + // portal nor the creating user is knowable on this path. return { displayName: 'HubSpot private app', + principal: null, auditMetadata: {}, - storedMetadata: {}, } } await throwForProviderResponse(res, 'account_info') @@ -53,8 +56,10 @@ async function verifyViaAccountInfo( const hubId = typeof info?.portalId === 'number' ? String(info.portalId) : undefined return { displayName: hubId ? `HubSpot portal ${hubId}` : 'HubSpot private app', - auditMetadata: hubId ? { hubspotHubId: hubId } : {}, - storedMetadata: hubId ? { hubId } : {}, + // This route never reports the private app's creating user, so the portal + // is the finest identity available here. + principal: hubId ? tenantPrincipal(hubId) : null, + auditMetadata: {}, } } @@ -113,10 +118,15 @@ export async function validateHubspotServiceAccount( const storedMetadata: Record = { hubId } if (typeof tokenInfo.appId === 'number') storedMetadata.appId = String(tokenInfo.appId) - if (typeof tokenInfo.userId === 'number') storedMetadata.userId = String(tokenInfo.userId) return { displayName: `HubSpot portal ${hubId}`, + // `userId` is the HubSpot user the private app acts on behalf of; it is the + // actor, while `hubId` is only the portal it lives in. + principal: + typeof tokenInfo.userId === 'number' + ? userPrincipal(String(tokenInfo.userId)) + : tenantPrincipal(hubId), auditMetadata: { hubspotHubId: hubId }, storedMetadata, } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts index 3007d161370..37ef8fc156c 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts @@ -39,8 +39,9 @@ describe('validateLinearServiceAccount', () => { expect(result).toEqual({ displayName: 'Acme', + principal: { kind: 'user', id: 'viewer-1', label: 'jane@acme.com' }, auditMetadata: { linearOrganizationId: 'org-1' }, - storedMetadata: { viewerId: 'viewer-1', organizationId: 'org-1' }, + storedMetadata: { organizationId: 'org-1' }, }) const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts index ea4e297f374..46c98aef7b0 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -127,15 +128,17 @@ export async function validateLinearServiceAccount( } const organization = payload.data?.organization - const storedMetadata: Record = { viewerId: viewer.id } + const storedMetadata: Record = {} const auditMetadata: Record = {} if (organization?.id) { storedMetadata.organizationId = organization.id auditMetadata.linearOrganizationId = organization.id } + const label = viewer.email || viewer.name || undefined return { displayName: organization?.name || viewer.name || viewer.email || 'Linear workspace', + principal: userPrincipal(viewer.id, label), auditMetadata, storedMetadata, } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts index e96e7006b72..590d425c79d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts @@ -38,8 +38,9 @@ describe('validateMondayServiceAccount', () => { expect(result).toEqual({ displayName: 'Acme', + principal: { kind: 'user', id: '12345', label: 'jane@example.com' }, auditMetadata: { mondayAccountId: '987' }, - storedMetadata: { accountId: '987', accountSlug: 'acme', userId: '12345' }, + storedMetadata: { accountId: '987', accountSlug: 'acme' }, }) expect(mockFetch).toHaveBeenCalledWith('https://api.monday.com/v2', { method: 'POST', @@ -159,6 +160,6 @@ describe('validateMondayServiceAccount', () => { ) const result = await validateMondayServiceAccount({ apiToken: 'token' }) expect(result.displayName).toBe('Acme') - expect(result.storedMetadata?.userId).toBe('77') + expect(result.principal).toEqual({ kind: 'user', id: '77', label: 'Bot User' }) }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts index 9ff853d71ff..75fa3fdbe9e 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -115,7 +116,7 @@ export async function validateMondayServiceAccount( const userId = String(me.id) const accountId = account?.id != null ? String(account.id) : '' - const storedMetadata: Record = { accountId, userId } + const storedMetadata: Record = { accountId } if (account?.slug) { storedMetadata.accountSlug = account.slug } @@ -123,9 +124,11 @@ export async function validateMondayServiceAccount( if (accountId) { auditMetadata.mondayAccountId = accountId } + const label = me.email || me.name return { displayName: account?.name || me.name || me.email || `monday user ${userId}`, + principal: userPrincipal(userId, label), auditMetadata, storedMetadata, } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts index fad5f227b30..adffbfb5a1f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts @@ -39,8 +39,9 @@ describe('validateNotionServiceAccount', () => { expect(result).toEqual({ displayName: 'Ops Integration', - auditMetadata: { notionBotId: 'bot-123' }, - storedMetadata: { botId: 'bot-123', workspaceName: 'Acme Workspace' }, + principal: { kind: 'user', id: 'bot-123', label: 'Ops Integration' }, + auditMetadata: {}, + storedMetadata: { workspaceName: 'Acme Workspace' }, }) expect(mockFetch).toHaveBeenCalledWith('https://api.notion.com/v1/users/me', { headers: { @@ -66,11 +67,9 @@ describe('validateNotionServiceAccount', () => { const result = await validateNotionServiceAccount({ apiToken: 'secret_legacy' }) expect(result.displayName).toBe('Acme Workspace') - expect(result.auditMetadata).toEqual({ notionBotId: 'bot-456' }) - expect(result.storedMetadata).toEqual({ - botId: 'bot-456', - workspaceName: 'Acme Workspace', - }) + expect(result.principal).toEqual({ kind: 'user', id: 'bot-456' }) + expect(result.auditMetadata).toEqual({}) + expect(result.storedMetadata).toEqual({ workspaceName: 'Acme Workspace' }) }) it('throws invalid_credentials on 401', async () => { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts index 4321ba5f3c4..e2b75762e48 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -56,14 +57,17 @@ export async function validateNotionServiceAccount( } const workspaceName = me.bot?.workspace_name || undefined - const storedMetadata: Record = { botId: me.id } + const storedMetadata: Record = {} if (workspaceName) { storedMetadata.workspaceName = workspaceName } return { displayName: me.name || workspaceName || 'Notion integration', - auditMetadata: { notionBotId: me.id }, + // The integration authenticates as its own bot user, which is the actor + // recorded on every page/database change it makes. + principal: userPrincipal(me.id, me.name), + auditMetadata: {}, storedMetadata, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.test.ts index 1e73129649b..4e75faeb57f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.test.ts @@ -48,8 +48,9 @@ describe('validatePipedriveServiceAccount', () => { expect(result).toEqual({ displayName: 'Jane Doe (Acme Inc)', + principal: { kind: 'user', id: '42', label: 'Jane Doe' }, auditMetadata: { pipedriveCompanyId: '777' }, - storedMetadata: { userId: '42', companyId: '777', companyDomain: 'acme' }, + storedMetadata: { companyId: '777', companyDomain: 'acme' }, }) expect(mockFetch).toHaveBeenCalledTimes(1) @@ -67,7 +68,8 @@ describe('validatePipedriveServiceAccount', () => { const result = await validatePipedriveServiceAccount(FIELDS) expect(result.displayName).toBe('Pipedrive company 777') - expect(result.storedMetadata).toEqual({ userId: '42', companyId: '777' }) + expect(result.principal).toEqual({ kind: 'user', id: '42' }) + expect(result.storedMetadata).toEqual({ companyId: '777' }) }) it('throws invalid_credentials on 401', async () => { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts index 3fa20e90ec6..66da8628dfa 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -62,7 +63,7 @@ export async function validatePipedriveServiceAccount( const companyDomain = typeof user.company_domain === 'string' && user.company_domain ? user.company_domain : undefined - const storedMetadata: Record = { userId: String(user.id) } + const storedMetadata: Record = {} if (companyId) storedMetadata.companyId = companyId if (companyDomain) storedMetadata.companyDomain = companyDomain @@ -76,6 +77,7 @@ export async function validatePipedriveServiceAccount( return { displayName, + principal: userPrincipal(String(user.id), userName), auditMetadata: companyId ? { pipedriveCompanyId: companyId } : {}, storedMetadata, } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts index b53fc10cff3..36eff828aac 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -44,8 +44,8 @@ describe('validateShopifyServiceAccount', () => { expect(result).toEqual({ displayName: 'Acme Store', - auditMetadata: { shopifyShopDomain: 'acme-store.myshopify.com' }, - storedMetadata: { shopDomain: 'acme-store.myshopify.com', shopName: 'Acme Store' }, + principal: { kind: 'tenant', id: 'acme-store.myshopify.com', label: 'Acme Store' }, + auditMetadata: {}, normalizedDomain: 'acme-store.myshopify.com', }) expect(mockFetch).toHaveBeenCalledWith( @@ -159,6 +159,28 @@ describe('validateShopifyServiceAccount', () => { }) }) + it('does not blame the credential when an auth-shaped error accompanies a populated shop', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { + data: { shop: { name: 'My Store', myshopifyDomain: 'my-store.myshopify.com' } }, + errors: [ + { message: 'Access denied for email field', extensions: { code: 'ACCESS_DENIED' } }, + ], + }) + ) + /** + * A per-field scope denial is not evidence the token is invalid. Reporting + * it as `invalid_credentials` would tell an admin to replace a working + * credential; only a response with no `shop` at all indicts the token. + */ + await expect( + validateShopifyServiceAccount({ apiToken: 'shpat_good', domain: 'my-store.myshopify.com' }) + ).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + }) + }) + it('normalizes a pasted admin URL down to the bare store host', async () => { mockFetch.mockResolvedValueOnce( jsonResponse(200, { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts index e0a2f9a605d..4d18a625334 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts @@ -18,6 +18,14 @@ import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' */ const SHOPIFY_HOST_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/ +/** + * Every selected field must stay scope-free. `hasShopifyAuthError` treats an + * auth-shaped GraphQL error as a rejected token, and that is only sound while + * this query cannot partially fail: adding a scoped field (anything guarded by + * `read_*`) makes Shopify answer a token missing that scope with HTTP 200, + * a populated `shop`, AND an `ACCESS_DENIED` error — a working credential that + * must not be rejected. Revisit that check before adding any field here. + */ const SHOP_QUERY = '{ shop { name myshopifyDomain } }' interface ShopifyGraphqlError { @@ -100,19 +108,29 @@ export async function validateShopifyServiceAccount( const payload = await parseProviderJson(res, 'shop_query') + // The auth heuristic only fires when the query returned nothing at all: an + // auth-shaped error alongside a populated `shop` is a partial-scope failure, + // not a rejected token, and blaming the credential there would be wrong. const shop = payload.data?.shop - if (hasShopifyAuthError(payload.errors)) { - throw new TokenServiceAccountValidationError('invalid_credentials', 401, { + if (!shop) { + if (hasShopifyAuthError(payload.errors)) { + throw new TokenServiceAccountValidationError('invalid_credentials', 401, { + step: 'shop_query', + domain, + reason: 'auth-shaped GraphQL error in 200 response', + }) + } + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'shop_query', domain, - reason: 'auth-shaped GraphQL error in 200 response', + reason: payload.errors ? 'GraphQL errors in response' : 'missing shop in response', }) } - if (payload.errors || !shop) { + if (payload.errors) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'shop_query', domain, - reason: payload.errors ? 'GraphQL errors in response' : 'missing shop in response', + reason: 'GraphQL errors in response', }) } @@ -122,13 +140,17 @@ export async function validateShopifyServiceAccount( ? normalizeShopifyDomain(shop.myshopifyDomain) : undefined const canonicalDomain = apiDomain && SHOPIFY_HOST_REGEX.test(apiDomain) ? apiDomain : domain - const storedMetadata: Record = { shopDomain: canonicalDomain } - if (shopName) storedMetadata.shopName = shopName + // A custom-app Admin API token belongs to the app, not to a staff member, so + // the store is the finest identity it can ever report. return { displayName: shopName ?? canonicalDomain, - auditMetadata: { shopifyShopDomain: canonicalDomain }, - storedMetadata, + principal: { + kind: 'tenant', + id: canonicalDomain, + ...(shopName ? { label: shopName } : {}), + }, + auditMetadata: {}, normalizedDomain: canonicalDomain, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts index 314da932186..82c76793363 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts @@ -46,8 +46,8 @@ describe('validateTrelloServiceAccount', () => { expect(result).toEqual({ displayName: 'Sim Bot', - auditMetadata: { trelloMemberId: 'abc123' }, - storedMetadata: { memberId: 'abc123', username: 'simbot' }, + principal: { kind: 'user', id: 'abc123', label: 'simbot' }, + auditMetadata: {}, }) const [url] = mockFetch.mock.calls[0] diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts index 209a43e8b2e..500a8c6b537 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts @@ -1,4 +1,5 @@ import { env } from '@/lib/core/config/env' +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -79,14 +80,12 @@ export async function validateTrelloServiceAccount( }) } - const storedMetadata: Record = { memberId: member.id } - if (typeof member.username === 'string' && member.username) { - storedMetadata.username = member.username - } + const username = + typeof member.username === 'string' && member.username ? member.username : undefined return { displayName: member.fullName || member.username || `Trello member ${member.id}`, - auditMetadata: { trelloMemberId: member.id }, - storedMetadata, + principal: userPrincipal(member.id, username), + auditMetadata: {}, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts index 50f8739d879..153faef2706 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts @@ -43,8 +43,8 @@ describe('validateWealthboxServiceAccount', () => { expect(result).toEqual({ displayName: 'Bill Jones', - auditMetadata: { wealthboxUserId: '42' }, - storedMetadata: { userId: '42', email: 'bill@example.com' }, + principal: { kind: 'user', id: '42', label: 'bill@example.com' }, + auditMetadata: {}, }) expect(mockFetch).toHaveBeenCalledTimes(1) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts index 94e85821f33..ac97ca76474 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -94,12 +95,11 @@ export async function validateWealthboxServiceAccount( const userId = typeof me.current_user?.id === 'number' ? String(me.current_user.id) : undefined const email = me.email || me.current_user?.email - const auditMetadata: Record = {} - if (userId) auditMetadata.wealthboxUserId = userId - - const storedMetadata: Record = {} - if (userId) storedMetadata.userId = userId - if (email) storedMetadata.email = email - - return { displayName, auditMetadata, storedMetadata } + // `/v1/me` omits `current_user` for some token types; without it Wealthbox + // reports no identifier of any kind on this response. + return { + displayName, + principal: userId ? userPrincipal(userId, email) : null, + auditMetadata: {}, + } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts index 1cb3f9018c8..109f862070b 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts @@ -35,8 +35,8 @@ describe('validateWebflowServiceAccount', () => { expect(result).toEqual({ displayName: 'Acme Marketing', - auditMetadata: { webflowSiteId: 'site123' }, - storedMetadata: { siteId: 'site123', siteName: 'Acme Marketing' }, + principal: { kind: 'tenant', id: 'site123', label: 'Acme Marketing' }, + auditMetadata: {}, }) expect(mockFetch).toHaveBeenCalledWith('https://api.webflow.com/v2/sites', { headers: { @@ -55,7 +55,7 @@ describe('validateWebflowServiceAccount', () => { const result = await validateWebflowServiceAccount({ apiToken: 'wf-token' }) expect(result.displayName).toBe('acme') - expect(result.storedMetadata).toEqual({ siteId: 'site456', siteName: 'acme' }) + expect(result.principal).toEqual({ kind: 'tenant', id: 'site456', label: 'acme' }) }) it('throws invalid_credentials on 401', async () => { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts index 034558532b2..4da3aeba131 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts @@ -1,3 +1,4 @@ +import { tenantPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -51,9 +52,10 @@ export async function validateWebflowServiceAccount( const displayName = site.displayName || site.shortName || 'Webflow site' + // A site API token is bound to a site, never to a Webflow user. return { displayName, - auditMetadata: { webflowSiteId: site.id }, - storedMetadata: { siteId: site.id, siteName: displayName }, + principal: tenantPrincipal(site.id, displayName), + auditMetadata: {}, } } diff --git a/apps/sim/lib/environment/utils.test.ts b/apps/sim/lib/environment/utils.test.ts index feb5c95bef0..91f0bdf6b3b 100644 --- a/apps/sim/lib/environment/utils.test.ts +++ b/apps/sim/lib/environment/utils.test.ts @@ -1,16 +1,27 @@ /** * @vitest-environment node */ -import { dbChainMockFns, encryptionMock, encryptionMockFns, resetDbChainMock } from '@sim/testing' +import { environment, workspaceEnvironment } from '@sim/db/schema' +import { + dbChainMockFns, + encryptionMock, + encryptionMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCreateWorkspaceEnvCredentials, + mockCheckWorkspaceAccess, + mockGetAccessibleEnvCredentials, mockGetUserEntityPermissions, mockGetWorkspaceEnvKeyAdminAccess, mockRecordAudit, } = vi.hoisted(() => ({ mockCreateWorkspaceEnvCredentials: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockGetAccessibleEnvCredentials: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), mockRecordAudit: vi.fn(), @@ -27,23 +38,87 @@ vi.mock('@sim/audit', () => ({ })) vi.mock('@/lib/credentials/environment', () => ({ createWorkspaceEnvCredentials: mockCreateWorkspaceEnvCredentials, - getAccessibleEnvCredentials: vi.fn(), + getAccessibleEnvCredentials: mockGetAccessibleEnvCredentials, getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess, syncPersonalEnvCredentialsForUser: vi.fn(), })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: vi.fn(), + checkWorkspaceAccess: mockCheckWorkspaceAccess, getUserEntityPermissions: mockGetUserEntityPermissions, })) import { getEffectiveDecryptedEnv, getEffectiveEnvironmentSnapshot, + getPersonalAndWorkspaceEnv, invalidateEffectiveDecryptedEnvCache, upsertWorkspaceEnvVars, WorkspaceEnvAccessError, } from '@/lib/environment/utils' +describe('getPersonalAndWorkspaceEnv access filtering', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: false, + }) + mockGetAccessibleEnvCredentials.mockResolvedValue([]) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({ + decrypted: `plain:${encryptedValue}`, + })) + }) + + it('filters every workspace secret when the caller has zero credential grants', async () => { + queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }]) + + const snapshot = await getPersonalAndWorkspaceEnv('user-1', 'workspace-1') + + expect(snapshot.personalDecrypted).toEqual({ PERSONAL_KEY: 'plain:personal-cipher' }) + expect(snapshot.workspaceDecrypted).toEqual({}) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledOnce() + }) + + it('preserves legacy workspace secrets without credential rows for workspace admins', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueTableRows(environment, [{ variables: {} }]) + queueTableRows(workspaceEnvironment, [{ variables: { LEGACY_KEY: 'legacy-cipher' } }]) + + const snapshot = await getPersonalAndWorkspaceEnv('admin-1', 'workspace-1') + + expect(snapshot.workspaceDecrypted).toEqual({ LEGACY_KEY: 'plain:legacy-cipher' }) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledOnce() + }) + + it('preserves shared-personal precedence when an accessible owner shares the same name', async () => { + mockGetAccessibleEnvCredentials.mockResolvedValue([ + { + type: 'env_personal', + envKey: 'SHARED_KEY', + envOwnerUserId: 'owner-2', + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + queueTableRows(environment, [{ variables: { SHARED_KEY: 'own-cipher' } }]) + queueTableRows(environment, [{ userId: 'owner-2', variables: { SHARED_KEY: 'shared-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: {} }]) + + const snapshot = await getPersonalAndWorkspaceEnv('user-1', 'workspace-1') + + expect(snapshot.personalDecrypted).toEqual({ SHARED_KEY: 'plain:shared-cipher' }) + expect(snapshot.personalOwners).toEqual({ SHARED_KEY: 'owner-2' }) + }) +}) + describe('upsertWorkspaceEnvVars', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index 37089698158..65d9b89be0b 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -48,6 +48,7 @@ export interface EnvironmentResolutionSnapshot { workspaceEncrypted: Record personalDecrypted: Record workspaceDecrypted: Record + personalOwners: Record conflicts: string[] decryptionFailures: string[] } @@ -75,6 +76,7 @@ function cloneEnvironmentResolutionSnapshot( workspaceEncrypted: { ...snapshot.workspaceEncrypted }, personalDecrypted: { ...snapshot.personalDecrypted }, workspaceDecrypted: { ...snapshot.workspaceDecrypted }, + personalOwners: { ...snapshot.personalOwners }, conflicts: [...snapshot.conflicts], decryptionFailures: [...snapshot.decryptionFailures], } @@ -165,7 +167,7 @@ export async function getPersonalAndWorkspaceEnv( const ownPersonalEncrypted: Record = (personalRows[0]?.variables as any) || {} const allWorkspaceEncrypted: Record = (workspaceRows[0]?.variables as any) || {} - const hasCredentialFiltering = Boolean(workspaceId) && accessibleEnvCredentials.length > 0 + const hasCredentialFiltering = Boolean(workspaceId) const workspaceCredentialKeys = new Set( accessibleEnvCredentials.filter((row) => row.type === 'env_workspace').map((row) => row.envKey) ) @@ -205,6 +207,9 @@ export async function getPersonalAndWorkspaceEnv( let personalEncrypted: Record = ownPersonalEncrypted let workspaceEncrypted: Record = allWorkspaceEncrypted + const personalOwners: Record = Object.fromEntries( + Object.keys(ownPersonalEncrypted).map((envKey) => [envKey, userId]) + ) if (hasCredentialFiltering) { personalEncrypted = { ...ownPersonalEncrypted } @@ -213,14 +218,17 @@ export async function getPersonalAndWorkspaceEnv( const encryptedValue = ownerVariables?.[envKey] if (encryptedValue) { personalEncrypted[envKey] = encryptedValue + personalOwners[envKey] = ownerUserId } } - workspaceEncrypted = Object.fromEntries( - Object.entries(allWorkspaceEncrypted).filter(([envKey]) => - workspaceCredentialKeys.has(envKey) - ) - ) + workspaceEncrypted = workspaceCanAdmin + ? { ...allWorkspaceEncrypted } + : Object.fromEntries( + Object.entries(allWorkspaceEncrypted).filter(([envKey]) => + workspaceCredentialKeys.has(envKey) + ) + ) } const decryptionFailures: string[] = [] @@ -268,6 +276,7 @@ export async function getPersonalAndWorkspaceEnv( workspaceEncrypted, personalDecrypted, workspaceDecrypted, + personalOwners, conflicts, decryptionFailures, } diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index f79f83d915d..ef73b1c11b6 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -380,6 +380,42 @@ describe('ExecutionLogger', () => { expect(compacted.traceSpans?.[0]?.children?.[0]).not.toHaveProperty('input') }) + test('retains the trusted Copilot binding in metadata-only compaction', () => { + const loggerInstance = new ExecutionLogger() as unknown as { + compactExecutionDataForStorage( + executionData: WorkflowExecutionLog['executionData'], + executionId: string + ): WorkflowExecutionLog['executionData'] + } + const correlation = { + executionId: 'execution-metadata-only', + requestId: 'request-1', + source: 'workflow' as const, + workflowId: 'workflow-1', + copilotToolCallId: 'tool-call-1', + } + + const compacted = loggerInstance.compactExecutionDataForStorage( + { + environment: { + variables: { OVERSIZED: 'x'.repeat(3.5 * 1024 * 1024) }, + workflowId: 'workflow-1', + executionId: 'execution-metadata-only', + userId: 'user-1', + workspaceId: 'workspace-1', + }, + correlation, + hasTraceSpans: false, + traceSpanCount: 0, + }, + 'execution-metadata-only' + ) + + expect(compacted.executionDataTruncated).toBe(true) + expect(compacted.correlation).toEqual(correlation) + expect(compacted).not.toHaveProperty('environment') + }) + test('retains tool-call structure when aggregate trace content exceeds the compaction cap', () => { const loggerInstance = new ExecutionLogger() as unknown as { compactExecutionDataForStorage( diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 897275d78f8..5394919c439 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -491,6 +491,7 @@ export class ExecutionLogger implements IExecutionLoggerService { ...(executionData.billingAttribution ? { billingAttribution: executionData.billingAttribution } : {}), + ...(executionData.correlation ? { correlation: executionData.correlation } : {}), hasTraceSpans: executionData.hasTraceSpans, traceSpanCount: executionData.traceSpanCount, tokens: executionData.tokens, diff --git a/apps/sim/lib/logs/execution/logging-session.test.ts b/apps/sim/lib/logs/execution/logging-session.test.ts index b4e79338534..eb1616e0869 100644 --- a/apps/sim/lib/logs/execution/logging-session.test.ts +++ b/apps/sim/lib/logs/execution/logging-session.test.ts @@ -92,7 +92,12 @@ vi.mock('@/lib/logs/execution/logging-factory', () => ({ models: {}, }), createEnvironmentObject: vi.fn(), - createTriggerObject: vi.fn(), + createTriggerObject: vi.fn((type: string, additionalData?: Record) => ({ + type, + source: type, + timestamp: '2026-01-01T00:00:00.000Z', + ...(additionalData ? { data: additionalData } : {}), + })), loadDeployedWorkflowStateForLogging: vi.fn(), loadWorkflowStateForExecution: loadWorkflowStateForExecutionMock, })) @@ -234,6 +239,40 @@ describe('LoggingSession start snapshots', () => { ) }) + it('persists only the server-validated execution correlation', async () => { + const session = new LoggingSession('workflow-1', 'execution-1', 'copilot', 'req-1') + const trustedCorrelation = { + executionId: 'execution-1', + requestId: 'req-1', + source: 'workflow', + workflowId: 'workflow-1', + triggerType: 'copilot', + copilotToolCallId: 'trusted-tool-call', + } + session.setTrustedExecutionCorrelation(trustedCorrelation) + + await session.start({ + userId: 'user-1', + workspaceId: 'workspace-1', + triggerData: { + correlation: { + executionId: 'submitted-execution', + requestId: 'submitted-request', + source: 'workflow', + copilotToolCallId: 'submitted-tool-call', + }, + }, + }) + + expect(startWorkflowExecutionMock).toHaveBeenCalledWith( + expect.objectContaining({ + trigger: expect.objectContaining({ + data: expect.objectContaining({ correlation: trustedCorrelation }), + }), + }) + ) + }) + it('does not create a log when hydrating a persisted execution for completion', async () => { const session = new LoggingSession('workflow-1', 'execution-existing', 'manual', 'req-existing') diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index 9703d5b57bd..bf96ef4022d 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -185,6 +185,7 @@ export class LoggingSession { private environment?: ExecutionEnvironment private workflowState?: WorkflowState private correlation?: NonNullable['correlation'] + private trustedExecutionCorrelation?: NonNullable['correlation'] private actorUserId: string | null = null private billingAttribution?: BillingAttributionSnapshot private isResume = false @@ -225,6 +226,13 @@ export class LoggingSession { this.resolvedSecretTraceRegistry = registry } + /** Adds server-validated lifecycle correlation without exposing it to executor metadata. */ + setTrustedExecutionCorrelation( + correlation: NonNullable['correlation']> + ): void { + this.trustedExecutionCorrelation = { ...correlation } + } + /** Adds the trusted execution-ref scope needed to rewrite offloaded trace content. */ setTraceLargeValueAccess(context: LargeValueStoreContext): void { this.traceLargeValueAccess = context @@ -618,8 +626,11 @@ export class LoggingSession { } try { - this.trigger = createTriggerObject(this.triggerType, triggerData) - this.correlation = triggerData?.correlation + const effectiveTriggerData = this.trustedExecutionCorrelation + ? { ...triggerData, correlation: this.trustedExecutionCorrelation } + : triggerData + this.trigger = createTriggerObject(this.triggerType, effectiveTriggerData) + this.correlation = effectiveTriggerData?.correlation this.environment = createEnvironmentObject( this.workflowId, this.executionId, @@ -1081,8 +1092,11 @@ export class LoggingSession { deploymentVersionId, workflowState, } = params - this.trigger = createTriggerObject(this.triggerType, triggerData) - this.correlation = triggerData?.correlation + const effectiveTriggerData = this.trustedExecutionCorrelation + ? { ...triggerData, correlation: this.trustedExecutionCorrelation } + : triggerData + this.trigger = createTriggerObject(this.triggerType, effectiveTriggerData) + this.correlation = effectiveTriggerData?.correlation this.environment = createEnvironmentObject( this.workflowId, this.executionId, diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.ts b/apps/sim/lib/logs/execution/trace-secret-projection.ts index 0632dc231c7..9164002569d 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.ts @@ -19,18 +19,18 @@ import type { LargeValueStoreContext } from '@/lib/execution/payloads/store' import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store' import type { ToolCall, TraceSpan } from '@/lib/logs/types' import type { IterationToolCall, ProviderTimingSegment } from '@/executor/types' -import type { - ResolvedSecretTraceMatch, - ResolvedSecretTraceRegistry, -} from '@/executor/utils/resolved-secret-trace-registry' +import { + containsResolvedSecret, + createResolvedSecretMatcher, + projectResolvedSecretContent, + type ResolvedSecretMatcher, +} from '@/executor/utils/resolved-secret-content-projection' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('TraceSecretProjection') const REF_CONCURRENCY = 4 const MAX_CONTENT_NODES = 100_000 const MAX_CONTENT_DEPTH = 100 -const MAX_MATCHER_NODES = 250_000 -const MAX_SECRET_LITERAL_LENGTH = 64 * 1024 -const MAX_MATCH_EVENTS = 1_000_000 const MAX_LARGE_VALUES = 1_024 const MAX_LARGE_VALUE_CHAIN_DEPTH = 32 const MAX_LARGE_MANIFEST_CHUNKS = MAX_LARGE_VALUES @@ -65,25 +65,8 @@ const LARGE_ARRAY_MANIFEST_KEYS = new Set([ ]) const LARGE_ARRAY_MANIFEST_CHUNK_KEYS = new Set(['ref', 'count', 'byteSize']) -interface SecretReplacement { - plaintext: string - replacement: string -} - -interface SecretTrieNode { - children: Map - failure?: SecretTrieNode - outputLink?: SecretTrieNode - replacement?: SecretReplacement -} - -interface SecretMatcher { - root: SecretTrieNode - maxPatternLength: number -} - interface ProjectionContext { - matcher: SecretMatcher + matcher: ResolvedSecretMatcher store: LargeValueStoreContext allowLargeValueWrites: boolean safeLargeValues: WeakSet @@ -113,13 +96,8 @@ interface TraversalState { ancestors: WeakSet } -interface SanitizationTraversalState extends TraversalState { - outputBytes: number - maxBytes: number -} - interface PlaintextInvariantContext { - matcher: SecretMatcher + matcher: ResolvedSecretMatcher safeLargeValues: WeakSet /** Valid refs are trusted only after the full projector has already rewritten them. */ allowVerifiedLargeValues?: boolean @@ -150,95 +128,8 @@ class TraceSecretProjectionError extends Error { } } -function compareStrings(left: string, right: string): number { - if (left < right) return -1 - if (left > right) return 1 - return 0 -} - -function normalizeReplacements(matches: readonly ResolvedSecretTraceMatch[]): SecretReplacement[] { - const replacementByPlaintext = new Map() - - for (const match of matches) { - if (!match.plaintext) continue - const current = replacementByPlaintext.get(match.plaintext) - if (current === undefined || compareStrings(match.replacement, current) < 0) { - replacementByPlaintext.set(match.plaintext, match.replacement) - } - } - - const provisional = [...replacementByPlaintext.keys()] - .map((plaintext) => { - const requested = replacementByPlaintext.get(plaintext) ?? '' - return { plaintext, replacement: requested } - }) - .sort( - (left, right) => - right.plaintext.length - left.plaintext.length || - compareStrings(left.replacement, right.replacement) || - compareStrings(left.plaintext, right.plaintext) - ) - - const detector = createSecretMatcher( - provisional.map(({ plaintext }) => ({ plaintext, replacement: '' })) - ) - return provisional.map(({ plaintext, replacement }) => ({ - plaintext, - replacement: containsSecret(replacement, detector) ? '' : replacement, - })) -} - -function createSecretMatcher(replacements: readonly SecretReplacement[]): SecretMatcher { - const root: SecretTrieNode = { children: new Map() } - root.failure = root - let nodeCount = 1 - let maxPatternLength = 0 - for (const replacement of replacements) { - if (replacement.plaintext.length > MAX_SECRET_LITERAL_LENGTH) { - throw new TraceSecretProjectionError('Secret literal exceeds the matcher size limit') - } - maxPatternLength = Math.max(maxPatternLength, replacement.plaintext.length) - let node = root - for (let index = 0; index < replacement.plaintext.length; index += 1) { - const character = replacement.plaintext[index] - let child = node.children.get(character) - if (!child) { - child = { children: new Map() } - node.children.set(character, child) - nodeCount += 1 - if (nodeCount > MAX_MATCHER_NODES) { - throw new TraceSecretProjectionError('Secret matcher node limit exceeded') - } - } - node = child - } - node.replacement = replacement - } - - const queue: SecretTrieNode[] = [] - for (const child of root.children.values()) { - child.failure = root - queue.push(child) - } - for (let cursor = 0; cursor < queue.length; cursor += 1) { - const node = queue[cursor] - for (const [character, child] of node.children) { - let fallback = node.failure ?? root - while (fallback !== root && !fallback.children.has(character)) { - fallback = fallback.failure ?? root - } - const transition = fallback.children.get(character) - child.failure = transition && transition !== child ? transition : root - child.outputLink = child.failure.replacement ? child.failure : child.failure.outputLink - queue.push(child) - } - } - - return { root, maxPatternLength } -} - function createProjectionContext( - matcher: SecretMatcher, + matcher: ResolvedSecretMatcher, store: LargeValueStoreContext, allowLargeValueWrites: boolean ): ProjectionContext { @@ -260,108 +151,6 @@ function createProjectionContext( } } -function advanceMatcher( - matcher: SecretMatcher, - node: SecretTrieNode, - character: string -): SecretTrieNode { - let current = node - while (current !== matcher.root && !current.children.has(character)) { - current = current.failure ?? matcher.root - } - return current.children.get(character) ?? matcher.root -} - -function containsSecret(value: string, matcher: SecretMatcher): boolean { - let node = matcher.root - for (let index = 0; index < value.length; index += 1) { - node = advanceMatcher(matcher, node, value[index]) - if (node.replacement || node.outputLink) return true - } - return false -} - -function sanitizeString( - value: string, - matcher: SecretMatcher, - maxBytes = MAX_INLINE_MATERIALIZATION_BYTES -): string { - if (maxBytes < 0) { - throw new TraceSecretProjectionError('Sanitized trace string exceeds the size limit') - } - if (Buffer.byteLength(value, 'utf8') > maxBytes) { - throw new TraceSecretProjectionError('Trace string exceeds the size limit') - } - if (matcher.maxPatternLength === 0 || value.length === 0) return value - - let emitCursor = 0 - let literalStart = 0 - let outputBytes = 0 - let matchEvents = 0 - const chunks: string[] = [] - const windowSize = matcher.maxPatternLength - const slotStarts = new Int32Array(windowSize) - const slotEnds = new Int32Array(windowSize) - slotStarts.fill(-1) - const slotReplacements = new Array(windowSize) - - const append = (chunk: string): void => { - if (!chunk) return - outputBytes += Buffer.byteLength(chunk, 'utf8') - if (outputBytes > maxBytes) { - throw new TraceSecretProjectionError('Sanitized trace string exceeds the size limit') - } - const lastIndex = chunks.length - 1 - if (lastIndex >= 0 && chunks[lastIndex].length + chunk.length <= 64 * 1024) { - chunks[lastIndex] += chunk - } else { - chunks.push(chunk) - } - } - - const finalizeThrough = (limit: number): void => { - while (emitCursor <= limit && emitCursor < value.length) { - const slot = emitCursor % windowSize - if (slotStarts[slot] === emitCursor && slotReplacements[slot] !== undefined) { - append(value.slice(literalStart, emitCursor)) - append(slotReplacements[slot] ?? '') - emitCursor = slotEnds[slot] - literalStart = emitCursor - } else { - emitCursor += 1 - } - } - } - - let node = matcher.root - for (let index = 0; index < value.length; index += 1) { - node = advanceMatcher(matcher, node, value[index]) - let outputNode: SecretTrieNode | undefined = node.replacement ? node : node.outputLink - while (outputNode?.replacement) { - matchEvents += 1 - if (matchEvents > MAX_MATCH_EVENTS) { - throw new TraceSecretProjectionError('Secret matcher event limit exceeded') - } - const start = index - outputNode.replacement.plaintext.length + 1 - if (start >= emitCursor) { - const slot = start % windowSize - const end = index + 1 - if (slotStarts[slot] !== start || end > slotEnds[slot]) { - slotStarts[slot] = start - slotEnds[slot] = end - slotReplacements[slot] = outputNode.replacement.replacement - } - } - outputNode = outputNode.outputLink - } - finalizeThrough(index - matcher.maxPatternLength + 1) - } - - finalizeThrough(value.length - 1) - append(value.slice(literalStart)) - return chunks.join('') -} - function visitNode(state: TraversalState, depth: number): void { state.nodes += 1 if (state.nodes > MAX_CONTENT_NODES) { @@ -591,75 +380,6 @@ function getLargeValueCandidate(value: unknown): LargeValueCandidate | undefined return value as LargeArrayManifest } -function sanitizeInlineValue( - value: unknown, - matcher: SecretMatcher, - safeLargeValues: WeakSet, - state: SanitizationTraversalState, - depth = 0 -): unknown { - visitNode(state, depth) - if (typeof value === 'string') { - const sanitized = sanitizeString(value, matcher, state.maxBytes - state.outputBytes) - state.outputBytes += Buffer.byteLength(sanitized, 'utf8') - return sanitized - } - if (value === null || typeof value === 'number' || typeof value === 'boolean') { - const rendered = String(value) - if (!containsSecret(rendered, matcher)) return value - const sanitized = sanitizeString(rendered, matcher, state.maxBytes - state.outputBytes) - state.outputBytes += Buffer.byteLength(sanitized, 'utf8') - return sanitized - } - if (value === undefined) return value - if (typeof value !== 'object') { - throw new TraceSecretProjectionError('Unsupported trace content value') - } - const largeValue = getLargeValueCandidate(value) - if (largeValue) { - if (!safeLargeValues.has(value as object)) { - throw new TraceSecretProjectionError('Trace content contains an unverified large value') - } - return value - } - if (!Array.isArray(value) && !isPlainRecord(value)) { - throw new TraceSecretProjectionError('Unsupported trace content object') - } - - enterObject(value, state) - try { - if (Array.isArray(value)) { - assertArrayFitsTraversal(value, state) - const sanitized = new Array(value.length) - for (const [index, item] of arrayDataEntries(value)) { - sanitized[index] = sanitizeInlineValue(item, matcher, safeLargeValues, state, depth + 1) - } - return sanitized - } - - const prototype = Object.getPrototypeOf(value) - const sanitized = Object.create(prototype) as Record - const sanitizedKeys = new Set() - for (const [key, item] of enumerableDataEntries(value)) { - const sanitizedKey = sanitizeString(key, matcher, state.maxBytes - state.outputBytes) - state.outputBytes += Buffer.byteLength(sanitizedKey, 'utf8') - if (sanitizedKeys.has(sanitizedKey)) { - throw new TraceSecretProjectionError('Secret replacement caused an object-key collision') - } - sanitizedKeys.add(sanitizedKey) - Object.defineProperty(sanitized, sanitizedKey, { - value: sanitizeInlineValue(item, matcher, safeLargeValues, state, depth + 1), - enumerable: true, - configurable: true, - writable: true, - }) - } - return sanitized - } finally { - leaveObject(value, state) - } -} - function collectLargeValues( value: unknown, refs: object[], @@ -840,12 +560,13 @@ async function sanitizeMaterializedValue( withinRefWorker = false ): Promise { const withSafeRefs = await replaceLargeValues(value, context, path, withinRefWorker) - return sanitizeInlineValue(withSafeRefs, context.matcher, context.safeLargeValues, { - nodes: 0, - ancestors: new WeakSet(), - outputBytes: 0, - maxBytes, + const projection = projectResolvedSecretContent(withSafeRefs, context.matcher, maxBytes, { + isOpaqueSafeObject: (candidate) => context.safeLargeValues.has(candidate), }) + if (!projection.safe) { + throw new TraceSecretProjectionError('Trace content could not be sanitized') + } + return projection.value } async function storeSanitizedLargeValue( @@ -1473,13 +1194,13 @@ function assertNoPlaintext( ): void { visitNode(state, depth) if (typeof value === 'string') { - if (containsSecret(value, context.matcher)) { + if (containsResolvedSecret(value, context.matcher)) { throw new TraceSecretProjectionError('Sanitized trace content still contains a secret') } return } if (value === null || typeof value === 'number' || typeof value === 'boolean') { - if (containsSecret(String(value), context.matcher)) { + if (containsResolvedSecret(String(value), context.matcher)) { throw new TraceSecretProjectionError('Sanitized trace primitive still contains a secret') } return @@ -1519,7 +1240,7 @@ function assertNoPlaintext( return } for (const [key, item] of enumerableDataEntries(value)) { - if (containsSecret(key, context.matcher)) { + if (containsResolvedSecret(key, context.matcher)) { throw new TraceSecretProjectionError('Sanitized trace key still contains a secret') } assertNoPlaintext(item, context, state, depth + 1) @@ -1746,7 +1467,7 @@ async function verifyPostTransformLargeValues( async function assertPostTransformTraceSpansAreSafe( traceSpans: TraceSpan[], - matcher: SecretMatcher, + matcher: ResolvedSecretMatcher, store: LargeValueStoreContext ): Promise { const projection = createProjectionContext(matcher, store, false) @@ -1788,14 +1509,10 @@ export async function enforceTraceSpanSecretInvariant( try { if (!options.registry?.isComplete()) return structuralOnlyTraceSpans(traceSpans) - const replacements = normalizeReplacements(options.registry.getActiveMatches()) - if (replacements.length === 0) return traceSpans + const matcher = createResolvedSecretMatcher(options.registry.getActiveMatches()) + if (!matcher) return traceSpans - await assertPostTransformTraceSpansAreSafe( - traceSpans, - createSecretMatcher(replacements), - options.store - ) + await assertPostTransformTraceSpansAreSafe(traceSpans, matcher, options.store) return traceSpans } catch { logger.warn('Trace secret invariant failed; retaining structural spans only') @@ -1816,11 +1533,11 @@ export async function projectTraceSpansForSecrets( } try { - const replacements = normalizeReplacements(options.registry.getActiveMatches()) - if (replacements.length === 0) return cloneTraceSpansForProjection(traceSpans) + const matcher = createResolvedSecretMatcher(options.registry.getActiveMatches()) + if (!matcher) return cloneTraceSpansForProjection(traceSpans) const context = createProjectionContext( - createSecretMatcher(replacements), + matcher, options.store, options.allowLargeValueWrites !== false ) diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index b1341811878..5655bbe877b 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -3,15 +3,27 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { decryptSecretMock } = vi.hoisted(() => ({ +const { decryptSecretMock, materializeLargeValueRefMock, storeLargeValueMock } = vi.hoisted(() => ({ decryptSecretMock: vi.fn(), + materializeLargeValueRefMock: vi.fn(), + storeLargeValueMock: vi.fn(), })) vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: decryptSecretMock, })) -import { projectExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +vi.mock('@/lib/execution/payloads/store', () => ({ + materializeLargeValueRef: materializeLargeValueRefMock, + storeLargeValue: storeLargeValueMock, +})) + +import { + externalizeExecutionData, + materializeExecutionData, + projectExecutionDataForDisplay, + TRACE_STORE_REF_KEY, +} from '@/lib/logs/execution/trace-store' const CONTEXT = { workspaceId: 'workspace-1', @@ -25,6 +37,55 @@ beforeEach(() => { decryptSecretMock.mockResolvedValue({ decrypted: '1234' }) }) +describe('execution data storage', () => { + it('keeps the trusted Copilot binding when an externalized payload is unavailable', async () => { + const correlation = { copilotToolCallId: 'tool-call-1' } + const ref = { + __simLargeValueRef: true, + version: 1, + id: 'lv_bbbbbbbbbbbb', + kind: 'object', + size: 128, + key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_bbbbbbbbbbbb.json', + executionId: 'execution-1', + preview: { unsafe: 'must-not-remain-inline' }, + } as const + storeLargeValueMock.mockResolvedValue(ref) + materializeLargeValueRefMock.mockRejectedValue(new Error('object unavailable')) + + const slim = await externalizeExecutionData( + { + correlation, + hasTraceSpans: true, + traceSpanCount: 2, + finalOutput: { unsafe: 'must-not-remain-inline' }, + }, + CONTEXT + ) + + expect(slim).toEqual({ + [TRACE_STORE_REF_KEY]: { + __simLargeValueRef: true, + version: 1, + id: 'lv_bbbbbbbbbbbb', + kind: 'object', + size: 128, + key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_bbbbbbbbbbbb.json', + executionId: 'execution-1', + }, + correlation, + hasTraceSpans: true, + traceSpanCount: 2, + }) + + await expect(materializeExecutionData(slim, CONTEXT)).resolves.toEqual({ + correlation, + hasTraceSpans: true, + traceSpanCount: 2, + }) + }) +}) + describe('projectExecutionDataForDisplay', () => { it('projects persisted output, input, errors, and spans from trusted provenance', async () => { const executionData = { diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index 16bf3b6cd75..f429bade600 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -20,14 +20,13 @@ export const TRACE_STORE_REF_KEY = 'traceStoreRef' /** * The only metadata kept inline on the slim row (everything else lives in the - * externalized object). These two describe trace presence/count and uniquely - * survive object expiry — so a reader can still report "trace data expired (N - * spans)" after retention without an object fetch. All other fields + * externalized object). Trace presence/count survives object expiry for log + * diagnostics, while correlation preserves the server-issued binding used to + * authenticate terminal Copilot workflow-tool executions. All other fields * (environment, trigger, tokens, models, truncation flags, and of course the - * heavy payloads) are in the stored object and recovered on materialize, so - * keeping them inline too would just be duplication. + * heavy payloads) are recovered from the stored object. */ -const INLINE_MARKER_KEYS = ['hasTraceSpans', 'traceSpanCount'] as const +const INLINE_MARKER_KEYS = ['hasTraceSpans', 'traceSpanCount', 'correlation'] as const /** * Read-path context. Resolves an externalized payload by storage key, authorized diff --git a/apps/sim/lib/mothership/inbox/executor.test.ts b/apps/sim/lib/mothership/inbox/executor.test.ts new file mode 100644 index 00000000000..f8231124a92 --- /dev/null +++ b/apps/sim/lib/mothership/inbox/executor.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckWorkspaceAccess, + mockGetUserEntityPermissions, + mockRunHeadlessCopilotLifecycle, + mockSendInboxResponse, +} = vi.hoisted(() => ({ + mockCheckWorkspaceAccess: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockRunHeadlessCopilotLifecycle: vi.fn(), + mockSendInboxResponse: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +vi.mock('@/lib/auth/ban', () => ({ + getActivelyBannedUserIds: vi.fn().mockResolvedValue([]), + isEmailBlocked: vi.fn().mockResolvedValue(false), +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveBillingAttribution: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + resolveOrCreateChat: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + appendCopilotChatMessages: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/payload', () => ({ + buildIntegrationToolSchemas: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/lib/copilot/chat/persisted-message', () => ({ + buildPersistedAssistantMessage: vi.fn().mockReturnValue({ id: 'assistant-message' }), + buildPersistedUserMessage: vi.fn().mockReturnValue({ id: 'user-message' }), +})) + +vi.mock('@/lib/copilot/chat/workspace-context', () => ({ + generateWorkspaceContext: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: vi.fn() }, +})) + +vi.mock('@/lib/copilot/entitlements', () => ({ + computeWorkspaceEntitlements: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/lib/copilot/request/lifecycle/headless', () => ({ + runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, +})) + +vi.mock('@/lib/copilot/request/lifecycle/start', () => ({ + requestChatTitle: vi.fn(), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isDocSandboxEnabled: false, + isHosted: true, +})) + +vi.mock('@/lib/mothership/inbox/agentmail-client', () => ({})) + +vi.mock('@/lib/mothership/inbox/response', () => ({ + sendInboxResponse: mockSendInboxResponse, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + uploadFile: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('owner-1'), +})) + +import { executeInboxTask } from '@/lib/mothership/inbox/executor' + +const INBOX_TASK = { + id: 'task-1', + workspaceId: 'workspace-1', + status: 'received', + fromEmail: 'sender@example.com', + fromName: 'Sender', + subject: 'Task', + bodyPreview: 'Please do this', + bodyText: 'Please do this', + bodyHtml: null, + hasAttachments: false, + agentmailMessageId: null, + chatId: 'chat-1', +} + +const WORKSPACE = { + id: 'workspace-1', + ownerId: 'owner-1', + inboxProviderId: 'provider-1', + inboxSecretScope: 'selected', + inboxMountedSecrets: ['INBOX_KEY'], +} + +describe('Inbox raw-secret actor', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckWorkspaceAccess.mockResolvedValue({ permission: 'write' }) + mockRunHeadlessCopilotLifecycle.mockResolvedValue({ + success: true, + content: 'done', + contentBlocks: [], + toolCalls: [], + chatId: 'chat-1', + }) + mockSendInboxResponse.mockResolvedValue('response-1') + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: 'task-1' }]) + .mockResolvedValueOnce([{ model: 'claude-opus-4-8' }]) + }) + + it('gives a workspace member their own raw-secret authority', async () => { + queueTableRows(schemaMock.mothershipInboxTask, [INBOX_TASK]) + queueTableRows(schemaMock.workspace, [WORKSPACE]) + queueTableRows(schemaMock.user, [{ id: 'member-1' }]) + mockGetUserEntityPermissions.mockResolvedValue('write') + + await executeInboxTask('task-1') + + expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + userId: 'member-1', + secretActorUserId: 'member-1', + secretMountPolicy: { + secretScope: 'selected', + mountedSecrets: ['INBOX_KEY'], + }, + }) + ) + }) + + it('keeps owner execution fallback but removes raw-secret authority for an external sender', async () => { + queueTableRows(schemaMock.mothershipInboxTask, [INBOX_TASK]) + queueTableRows(schemaMock.workspace, [WORKSPACE]) + queueTableRows(schemaMock.user, []) + + await executeInboxTask('task-1') + + expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + userId: 'owner-1', + secretActorUserId: null, + secretMountPolicy: { + secretScope: 'selected', + mountedSecrets: ['INBOX_KEY'], + }, + }) + ) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 21fcc48c286..4359130a79d 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -18,6 +18,7 @@ import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start' import type { OrchestratorResult } from '@/lib/copilot/request/types' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' @@ -64,6 +65,8 @@ export async function executeInboxTask(taskId: string): Promise { id: workspace.id, ownerId: workspace.ownerId, inboxProviderId: workspace.inboxProviderId, + inboxSecretScope: workspace.inboxSecretScope, + inboxMountedSecrets: workspace.inboxMountedSecrets, }) .from(workspace) .where(eq(workspace.id, inboxTask.workspaceId)) @@ -82,14 +85,15 @@ export async function executeInboxTask(taskId: string): Promise { let responseSent = false try { - const [[claimed], userId] = await Promise.all([ + const [[claimed], actor] = await Promise.all([ db .update(mothershipInboxTask) .set({ status: 'processing', processingStartedAt: new Date() }) .where(and(eq(mothershipInboxTask.id, taskId), eq(mothershipInboxTask.status, 'received'))) .returning({ id: mothershipInboxTask.id }), - resolveUserId(inboxTask.fromEmail, ws), + resolveInboxExecutionActor(inboxTask.fromEmail, ws), ]) + const userId = actor.executionUserId if (!claimed) { logger.info('Task already claimed by another execution, skipping', { taskId }) @@ -252,6 +256,12 @@ export async function executeInboxTask(taskId: string): Promise { autoExecuteTools: true, interactive: false, billingAttribution, + ...(userPermission ? { userPermission } : {}), + secretActorUserId: actor.secretActorUserId, + secretMountPolicy: normalizeSecretMountPolicy({ + secretScope: ws.inboxSecretScope, + mountedSecrets: ws.inboxMountedSecrets, + }), }) const cleanContent = stripThinkingTags(result.content || '') @@ -328,13 +338,19 @@ export async function executeInboxTask(taskId: string): Promise { } /** - * Resolve which user ID to use for execution. - * Match sender email to a workspace member, fallback to workspace owner. + * Resolve the execution and raw-secret actors independently. Workspace members + * execute and mount secrets as themselves. External senders retain the existing + * owner execution fallback but receive no raw-secret actor. */ -async function resolveUserId( +interface InboxExecutionActor { + executionUserId: string + secretActorUserId: string | null +} + +async function resolveInboxExecutionActor( senderEmail: string, ws: { id: string; ownerId: string } -): Promise { +): Promise { const [matchedUser] = await db .select({ id: user.id }) .from(user) @@ -345,11 +361,11 @@ async function resolveUserId( if (matchedUser) { const permission = await getUserEntityPermissions(matchedUser.id, 'workspace', ws.id) if (permission !== null) { - return matchedUser.id + return { executionUserId: matchedUser.id, secretActorUserId: matchedUser.id } } } - return ws.ownerId + return { executionUserId: ws.ownerId, secretActorUserId: null } } /** diff --git a/apps/sim/lib/oauth/microsoft.ts b/apps/sim/lib/oauth/microsoft.ts index 1e9be406f2a..8da533eee72 100644 --- a/apps/sim/lib/oauth/microsoft.ts +++ b/apps/sim/lib/oauth/microsoft.ts @@ -1,3 +1,13 @@ +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' + +/** + * Scoped `'Auth'` because these lines are emitted from the OAuth callback path + * and were logged under that scope before this helper moved here; renaming the + * scope would break existing log queries and alerts. + */ +const logger = createLogger('Auth') + const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90 export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7 @@ -43,3 +53,53 @@ export function deriveMicrosoftEmailVerified( (Array.isArray(verifiedSecondary) && verifiedSecondary.includes(email)) ) } + +/** + * Extracts user info from a Microsoft ID token JWT instead of calling Graph API /me. + * This avoids 403 errors for external tenant users whose admin hasn't consented to Graph API scopes. + * The ID token is always returned when the openid scope is requested. + */ +export function getMicrosoftUserInfoFromIdToken( + tokens: { accessToken?: string }, + providerId: string +) { + const idToken = (tokens as Record).idToken as string | undefined + if (!idToken) { + logger.error( + `Microsoft ${providerId} OAuth: no ID token received. Ensure openid scope is requested.` + ) + throw new Error(`Microsoft ${providerId} OAuth requires an ID token (openid scope)`) + } + + const parts = idToken.split('.') + if (parts.length !== 3) { + throw new Error(`Microsoft ${providerId} OAuth: malformed ID token`) + } + + let payload: Record + try { + payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) + } catch { + throw new Error(`Microsoft ${providerId} OAuth: failed to decode ID token payload`) + } + + const email = + (payload.email as string) || (payload.preferred_username as string) || (payload.upn as string) + if (!email) { + throw new Error( + `Microsoft ${providerId} OAuth: ID token contains no email, preferred_username, or upn claim` + ) + } + + const emailVerified = deriveMicrosoftEmailVerified(payload, email) + + const now = new Date() + return { + id: `${payload.oid || payload.sub}-${generateId()}`, + name: (payload.name as string) || 'Microsoft User', + email, + emailVerified, + createdAt: now, + updatedAt: now, + } +} diff --git a/apps/sim/lib/workflows/executor/execution-state.test.ts b/apps/sim/lib/workflows/executor/execution-state.test.ts index 39aa68c066d..ae72a5e218f 100644 --- a/apps/sim/lib/workflows/executor/execution-state.test.ts +++ b/apps/sim/lib/workflows/executor/execution-state.test.ts @@ -19,6 +19,7 @@ import { getExecutionInputForWorkflow, getExecutionStateForWorkflow, getLatestExecutionStateWithExecutionId, + getTrustedWorkflowToolExecution, } from '@/lib/workflows/executor/execution-state' const EXECUTION_STATE = { @@ -75,6 +76,186 @@ describe('execution state lookup', () => { expect(result).toEqual(EXECUTION_STATE) }) + it('loads a terminal workflow result with an exact persisted Copilot binding', async () => { + const provenance = { + version: 1 as const, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + } + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + finalOutput: { token: 'raw-secret' }, + executionState: { ...EXECUTION_STATE, resolvedSecretTraceProvenance: provenance }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toEqual({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + contentAvailable: true, + finalOutput: { token: 'raw-secret' }, + blockLogs: [], + provenance, + }) + }) + + it('accepts a bound complete execution with no activated secrets', async () => { + const provenance = { version: 1 as const, complete: true, entries: [] } + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + trigger: { data: { correlation: { copilotToolCallId: 'tool-call-1' } } }, + executionState: { ...EXECUTION_STATE, resolvedSecretTraceProvenance: provenance }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toMatchObject({ provenance }) + }) + + it('returns validated incomplete provenance so the terminal projector can fail closed', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'failed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + executionState: { + ...EXECUTION_STATE, + resolvedSecretTraceProvenance: { version: 1, complete: false, entries: [] }, + }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toMatchObject({ + status: 'failed', + provenance: { version: 1, complete: false, entries: [] }, + }) + }) + + it('trusts compacted terminal status while withholding unavailable execution content', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + executionStateSummary: { + executedBlockCount: 1, + blockLogCount: 1, + completedLoopCount: 0, + activeExecutionPathLength: 0, + pendingQueueLength: 0, + }, + finalOutput: { token: 'must-not-cross' }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toEqual({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + contentAvailable: false, + }) + }) + + it('withholds execution content when persisted provenance is malformed', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + finalOutput: { token: 'must-not-cross' }, + executionState: { + ...EXECUTION_STATE, + resolvedSecretTraceProvenance: { version: 2, complete: true, entries: [] }, + }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toEqual({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + contentAvailable: false, + }) + }) + + it('rejects mismatched bindings and nonterminal rows', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'another-tool-call' }, + executionState: { + ...EXECUTION_STATE, + resolvedSecretTraceProvenance: { version: 1, complete: true, entries: [] }, + }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toBeNull() + + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-2', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'running', + executionData: {}, + }, + ]) + + await expect( + getTrustedWorkflowToolExecution('execution-2', 'workflow-1', 'tool-call-1') + ).resolves.toBeNull() + }) + it('materializes externalized execution data when reusing workflow input', async () => { const slimExecutionData = { traceStoreRef: { diff --git a/apps/sim/lib/workflows/executor/execution-state.ts b/apps/sim/lib/workflows/executor/execution-state.ts index c854890d71e..4f0b8eacfbf 100644 --- a/apps/sim/lib/workflows/executor/execution-state.ts +++ b/apps/sim/lib/workflows/executor/execution-state.ts @@ -4,6 +4,10 @@ import { isRecordLike } from '@sim/utils/object' import { and, desc, eq, or, sql } from 'drizzle-orm' import { materializeExecutionData, TRACE_STORE_REF_KEY } from '@/lib/logs/execution/trace-store' import type { SerializableExecutionState } from '@/executor/execution/types' +import { + isResolvedSecretTraceProvenanceV1, + type ResolvedSecretTraceProvenanceV1, +} from '@/executor/utils/resolved-secret-trace-registry' const LATEST_EXECUTION_STATE_CANDIDATE_LIMIT = 10 @@ -54,37 +58,43 @@ interface ExecutionStateRow { executionId: string workflowId: string | null workspaceId: string + status?: string executionData: unknown } -async function materializeExecutionDataFromRow( - row: ExecutionStateRow | undefined -): Promise | null> { - if (!row) return null +interface TrustedWorkflowToolExecutionBase { + executionId: string + workflowId: string + status: 'completed' | 'failed' | 'cancelled' +} - return materializeExecutionData(row.executionData as Record | null, { - workspaceId: row.workspaceId, - workflowId: row.workflowId, - executionId: row.executionId, - }) +export interface TrustedWorkflowToolExecutionWithoutContent + extends TrustedWorkflowToolExecutionBase { + contentAvailable: false } -async function extractExecutionStateFromRow( - row: ExecutionStateRow | undefined -): Promise { - const executionData = await materializeExecutionDataFromRow(row) - return extractExecutionState(executionData) +export interface TrustedWorkflowToolExecutionWithContent extends TrustedWorkflowToolExecutionBase { + contentAvailable: true + finalOutput?: unknown + error?: string + blockLogs: SerializableExecutionState['blockLogs'] + provenance: ResolvedSecretTraceProvenanceV1 } -export async function getExecutionStateForWorkflow( +export type TrustedWorkflowToolExecution = + | TrustedWorkflowToolExecutionWithoutContent + | TrustedWorkflowToolExecutionWithContent + +async function getExecutionStateRow( executionId: string, workflowId: string -): Promise { +): Promise { const [row] = await db .select({ executionId: workflowExecutionLogs.executionId, workflowId: workflowExecutionLogs.workflowId, workspaceId: workflowExecutionLogs.workspaceId, + status: workflowExecutionLogs.status, executionData: workflowExecutionLogs.executionData, }) .from(workflowExecutionLogs) @@ -96,9 +106,94 @@ export async function getExecutionStateForWorkflow( ) .limit(1) + return row +} + +async function materializeExecutionDataFromRow( + row: ExecutionStateRow | undefined +): Promise | null> { + if (!row) return null + + return materializeExecutionData(row.executionData as Record | null, { + workspaceId: row.workspaceId, + workflowId: row.workflowId, + executionId: row.executionId, + }) +} + +async function extractExecutionStateFromRow( + row: ExecutionStateRow | undefined +): Promise { + const executionData = await materializeExecutionDataFromRow(row) + return extractExecutionState(executionData) +} + +export async function getExecutionStateForWorkflow( + executionId: string, + workflowId: string +): Promise { + const row = await getExecutionStateRow(executionId, workflowId) return extractExecutionStateFromRow(row) } +/** Loads a terminal workflow result only when its server-persisted Copilot binding matches. */ +export async function getTrustedWorkflowToolExecution( + executionId: string, + workflowId: string, + copilotToolCallId: string +): Promise { + const row = await getExecutionStateRow(executionId, workflowId) + if ( + !row || + (row.status !== 'completed' && row.status !== 'failed' && row.status !== 'cancelled') + ) { + return null + } + + const executionData = await materializeExecutionDataFromRow(row) + const state = extractExecutionState(executionData) + const provenance = state?.resolvedSecretTraceProvenance + const topLevelCorrelation = executionData?.correlation + const triggerCorrelation = isRecordLike(executionData?.trigger) + ? executionData.trigger.data + : undefined + const correlation = isRecordLike(topLevelCorrelation) + ? topLevelCorrelation + : isRecordLike(triggerCorrelation) && isRecordLike(triggerCorrelation.correlation) + ? triggerCorrelation.correlation + : undefined + + if ( + !executionData || + !isRecordLike(correlation) || + correlation.copilotToolCallId !== copilotToolCallId + ) { + return null + } + + if (!state || !isResolvedSecretTraceProvenanceV1(provenance)) { + return { + executionId, + workflowId, + status: row.status, + contentAvailable: false, + } + } + + return { + executionId, + workflowId, + status: row.status, + contentAvailable: true, + ...(Object.hasOwn(executionData, 'finalOutput') + ? { finalOutput: executionData.finalOutput } + : {}), + ...(typeof executionData.error === 'string' ? { error: executionData.error } : {}), + blockLogs: state.blockLogs, + provenance, + } +} + /** * Returns the workflow input recorded for a past execution so a new run can * reuse it by reference. `found` distinguishes a missing execution from an @@ -108,21 +203,7 @@ export async function getExecutionInputForWorkflow( executionId: string, workflowId: string ): Promise<{ found: boolean; input?: unknown }> { - const [row] = await db - .select({ - executionId: workflowExecutionLogs.executionId, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionData: workflowExecutionLogs.executionData, - }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.executionId, executionId), - eq(workflowExecutionLogs.workflowId, workflowId) - ) - ) - .limit(1) + const row = await getExecutionStateRow(executionId, workflowId) if (!row) { return { found: false } diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts index 7b48bab36e1..9ee006b26be 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts @@ -89,4 +89,24 @@ describe('performChatDeploy password guards', () => { error: 'Password is required when using password protection', }) }) + + it('does not create a chat from a historical active deployment attempt', async () => { + mockGetWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: { status: 'active', isCurrent: false }, + warnings: [], + }) + mockPerformFullDeploy.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { status: 'active', isCurrent: false }, + }) + + const result = await performChatDeploy(basePayload) + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('historical'), + }) + }) }) diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 106cb090df0..cdfffe62237 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -35,6 +35,8 @@ export interface ChatDeployPayload { /** When true, public SSE may expose tool lifecycle if the client opts into agent-events-v1. */ includeToolCalls?: boolean workspaceId?: string | null + /** Stable identity for the underlying workflow deployment operation. */ + idempotencyKey?: string } export interface PerformChatDeployResult { @@ -114,10 +116,18 @@ export async function performChatDeploy( userId, versionDescription: params.versionDescription, versionName: params.versionName, + idempotencyKey: params.idempotencyKey, }) if (!deployResult.success) { return { success: false, error: deployResult.error || 'Failed to deploy workflow' } } + if (deployResult.latestDeploymentAttempt?.isCurrent === false) { + return { + success: false, + error: + 'The workflow deployment attempt is historical and no longer describes production. Retry chat deployment as a new tool call.', + } + } if (deployResult.latestDeploymentAttempt?.status !== 'active') { return { success: false, @@ -126,6 +136,12 @@ export async function performChatDeploy( 'Workflow deployment is still preparing. Retry chat deployment after it becomes active.', } } + if (!deployResult.activeDeployment) { + return { + success: false, + error: 'Workflow deployment reported active without a live deployment version.', + } + } } let encryptedPassword: string | null = null diff --git a/apps/sim/lib/workflows/orchestration/deploy.test.ts b/apps/sim/lib/workflows/orchestration/deploy.test.ts index e70aea0eba0..4d194892e17 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.test.ts @@ -101,6 +101,7 @@ vi.mock('@/lib/workflows/schedules', () => ({ // Resolves to the global @sim/platform-authz/workflow mock, so instanceof matches. import { WorkflowLockedError } from '@sim/platform-authz/workflow' import { + getWorkflowDeploymentSummary, performActivateVersion, performFullDeploy, performFullUndeploy, @@ -262,6 +263,50 @@ describe('performFullDeploy workspace event emission', () => { }) }) + it('marks the latest active operation historical when no matching version is live', async () => { + const now = new Date('2026-07-14T08:00:00.000Z') + mockGetWorkflowDeploymentStatus.mockResolvedValueOnce({ + activeDeployment: null, + latestOperation: { + id: 'operation-historical', + workflowId: 'workflow-1', + deploymentVersionId: 'dv-old', + version: 3, + previousActiveVersionId: null, + action: 'deploy', + protocolVersion: 2, + generation: 1, + status: 'active', + componentReadiness: { + webhooks: { status: 'ready', updatedAt: now.toISOString() }, + schedules: { status: 'ready', updatedAt: now.toISOString() }, + mcp: { status: 'ready', updatedAt: now.toISOString() }, + }, + errorCode: null, + errorMessage: null, + idempotencyKey: 'request-historical', + requestHash: 'hash', + actorId: 'user-1', + completedAt: now, + createdAt: now, + updatedAt: now, + }, + }) + + const result = await getWorkflowDeploymentSummary('workflow-1') + + expect(result).toMatchObject({ + activeDeployment: null, + latestDeploymentAttempt: { + id: 'operation-historical', + status: 'active', + isCurrent: false, + error: null, + }, + warnings: [expect.stringContaining('historical')], + }) + }) + it('always admits deploys through v2 without legacy immediate activation', async () => { const result = await performFullDeploy({ workflowId: 'workflow-1', @@ -277,6 +322,62 @@ describe('performFullDeploy workspace event emission', () => { expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() }) + it('does not reuse a correlation request ID as an implicit idempotency key', async () => { + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + ]) + + const params = { workflowId: 'workflow-1', userId: 'user-1', requestId: 'request-1' } + await performFullDeploy(params) + await performFullDeploy(params) + + const firstKey = mockPrepareWorkflowDeployment.mock.calls[0][0].idempotencyKey + const secondKey = mockPrepareWorkflowDeployment.mock.calls[1][0].idempotencyKey + expect(firstKey).toEqual(expect.any(String)) + expect(secondKey).toEqual(expect.any(String)) + expect(firstKey).not.toBe('request-1') + expect(firstKey).not.toBe(secondKey) + }) + + it('keeps the request hash stable across snapshot timestamps and edge order', async () => { + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + ]) + const baseState = { + blocks: {}, + edges: [ + { id: 'edge-b', source: 'block-2', target: 'block-3' }, + { id: 'edge-a', source: 'block-1', target: 'block-2' }, + ], + loops: {}, + parallels: {}, + variables: {}, + lastSaved: 1, + } + mockLoadWorkflowDeploymentSnapshot.mockResolvedValueOnce(baseState).mockResolvedValueOnce({ + ...baseState, + edges: [...baseState.edges].reverse(), + lastSaved: 2, + }) + + const params = { + workflowId: 'workflow-1', + userId: 'user-1', + idempotencyKey: 'copilot:execution-1:tool-call:call-1', + } + await performFullDeploy(params) + await performFullDeploy(params) + + expect(mockPrepareWorkflowDeployment.mock.calls[0][0].requestHash).toBe( + mockPrepareWorkflowDeployment.mock.calls[1][0].requestHash + ) + expect(mockPrepareWorkflowDeployment.mock.calls[0][0].idempotencyKey).toBe( + 'copilot:execution-1:tool-call:call-1' + ) + }) + it('keeps a first deploy pending without claiming an active deployment', async () => { const now = new Date('2026-07-14T08:00:00.000Z') const operation = { diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 2abd4e04e67..5ab57a53b0d 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { env } from '@/lib/core/config/env' @@ -11,6 +12,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { captureServerEvent } from '@/lib/posthog/server' import { validateTriggerWebhookConfigForDeploy } from '@/lib/webhooks/deploy' +import { normalizedStringify } from '@/lib/workflows/comparison/normalize' import { DEPLOYMENT_ERROR_CODES, type DeploymentComponentStatus, @@ -59,6 +61,8 @@ export interface DeploymentAttemptResult { version: number action: 'deploy' | 'activate' status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + /** Whether this attempt still describes the workflow's current deployment lifecycle. */ + isCurrent: boolean readiness: { webhooks: DeploymentReadinessSummaryStatus schedules: DeploymentReadinessSummaryStatus @@ -88,6 +92,9 @@ export interface PerformFullDeployParams { * endpoint, so it stays optional here. */ versionName?: string + /** Stable identity for one logical deployment operation. */ + idempotencyKey?: string + /** Correlation ID for logging and outbox tracing. */ requestId?: string /** * Override the actor ID used in audit logs and the `deployedBy` field. @@ -125,7 +132,9 @@ export interface PerformFullDeployResult { /** * Admits a deployment through the v2 prepare/activate protocol. The candidate - * version remains inactive until every required side effect is ready. + * version remains inactive until every required side effect is ready. Callers + * that can replay a logical operation must provide a stable `idempotencyKey`; + * `requestId` is correlation metadata only. */ export async function performFullDeploy( params: PerformFullDeployParams @@ -133,6 +142,7 @@ export async function performFullDeploy( const { workflowId, userId } = params const actorId = params.actorId ?? userId const requestId = params.requestId ?? generateRequestId() + const idempotencyKey = params.idempotencyKey ?? generateId() // Backstop for every caller — routes may assert first to render their own 423, // but the copilot deploy tools call this directly. @@ -154,6 +164,7 @@ export async function performFullDeploy( params, actorId, requestId, + idempotencyKey, }) } catch (error) { logger.error(`[${requestId}] Deployment preparation failed`, { workflowId, error }) @@ -169,6 +180,7 @@ async function performStableFullDeploy(params: { params: PerformFullDeployParams actorId: string requestId: string + idempotencyKey: string }): Promise { const workflowState = await loadWorkflowDeploymentSnapshot(params.params.workflowId) if (!workflowState) { @@ -190,11 +202,11 @@ async function performStableFullDeploy(params: { action: 'deploy', workflowId: params.params.workflowId, userId: params.params.userId, - workflowState, + workflowState: canonicalizeDeploymentWorkflowState(workflowState), versionName: params.params.versionName ?? null, versionDescription: params.params.versionDescription ?? null, }), - idempotencyKey: params.requestId, + idempotencyKey: params.idempotencyKey, workflowState, name: params.params.versionName, description: params.params.versionDescription, @@ -291,8 +303,22 @@ async function validateDeploymentState( return { success: true } } +function canonicalizeDeploymentWorkflowState( + workflowState: WorkflowState +): Record { + const { lastSaved: _lastSaved, edges, ...stableState } = workflowState + const sortedEdges = [...edges].sort((left, right) => { + if (left.id !== right.id) return left.id < right.id ? -1 : 1 + const normalizedLeft = normalizedStringify(left) + const normalizedRight = normalizedStringify(right) + if (normalizedLeft === normalizedRight) return 0 + return normalizedLeft < normalizedRight ? -1 : 1 + }) + return { ...stableState, edges: sortedEdges } +} + function createDeploymentRequestHash(value: Record): string { - return sha256Hex(JSON.stringify(value)) + return sha256Hex(normalizedStringify(value)) } function mapPrepareFailureCode( @@ -337,7 +363,10 @@ function buildStableDeploymentResult( deployedAt: status.activeDeployment.deployedAt.toISOString(), } : null - const latestDeploymentAttempt = summarizeDeploymentOperation(status.latestOperation) + const latestDeploymentAttempt = summarizeDeploymentOperation( + status.latestOperation, + status.activeDeployment?.deploymentVersionId ?? null + ) const warning = getStableDeploymentWarning( latestDeploymentAttempt, processResult, @@ -375,7 +404,8 @@ export async function getWorkflowDeploymentSummary(workflowId: string): Promise< } function summarizeDeploymentOperation( - operation: WorkflowDeploymentOperation | null + operation: WorkflowDeploymentOperation | null, + activeDeploymentVersionId: string | null ): DeploymentAttemptResult | null { if (!operation) return null if ( @@ -395,6 +425,10 @@ function summarizeDeploymentOperation( version: operation.version, action: operation.action, status: operation.status, + isCurrent: + operation.status === 'active' + ? operation.deploymentVersionId === activeDeploymentVersionId + : operation.status !== 'superseded', readiness: { webhooks: componentStatus('webhooks'), schedules: componentStatus('schedules'), @@ -420,6 +454,9 @@ function getStableDeploymentWarning( hasActiveDeployment: boolean ): string | undefined { if (!attempt) return undefined + if (attempt.status === 'active' && !attempt.isCurrent) { + return 'The latest successful deployment attempt is historical; no matching deployment version is currently active.' + } if (attempt.status === 'preparing' || attempt.status === 'activating') { if (processResult === 'processing_error') { return hasActiveDeployment @@ -547,6 +584,9 @@ export interface PerformActivateVersionParams { workflowId: string version: number userId: string + /** Stable identity for one logical activation operation. */ + idempotencyKey?: string + /** Correlation ID for logging and outbox tracing. */ requestId?: string /** Override the actor ID used in audit logs. Defaults to `userId`. */ actorId?: string @@ -582,7 +622,8 @@ export interface PerformRevertToVersionResult { } /** - * Admits an existing version through the v2 prepare/activate protocol. + * Admits an existing version through the v2 prepare/activate protocol. Callers + * that can replay a logical operation must provide a stable `idempotencyKey`. */ export async function performActivateVersion( params: PerformActivateVersionParams @@ -590,6 +631,7 @@ export async function performActivateVersion( const { workflowId, version, userId } = params const actorId = params.actorId ?? userId const requestId = params.requestId ?? generateRequestId() + const idempotencyKey = params.idempotencyKey ?? generateId() const lockDenial = await workflowLockDenial(workflowId) if (lockDenial) return { success: false, error: lockDenial, errorCode: 'validation' } @@ -665,6 +707,7 @@ export async function performActivateVersion( userId, actorId, requestId, + idempotencyKey, }) } catch (error) { logger.error(`[${requestId}] Version activation preparation failed`, { @@ -687,6 +730,7 @@ async function performStableVersionActivation(params: { userId: string actorId: string requestId: string + idempotencyKey: string }): Promise { let outboxEventId: string | undefined const prepared = await prepareWorkflowVersionActivation({ @@ -700,7 +744,7 @@ async function performStableVersionActivation(params: { version: params.version, userId: params.userId, }), - idempotencyKey: params.requestId, + idempotencyKey: params.idempotencyKey, readinessComponents: DEPLOYMENT_READINESS_COMPONENTS, onPrepareTransaction: async (tx, operation) => { if (!operation.deploymentVersionId || operation.version === null) { diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts index 404af18c04f..07f541e553f 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts @@ -42,13 +42,27 @@ const multiTriggerConfig = { ], } +const mothershipConfig = { + type: 'mothership', + name: 'Sim Chat', + category: 'blocks', + outputs: {}, + subBlocks: [ + { id: 'prompt', type: 'long-input' }, + { id: 'secretScope', type: 'dropdown', hideFromCopilot: true }, + { id: 'mountedSecrets', type: 'dropdown', hideFromCopilot: true }, + ], +} + vi.mock('@/blocks/registry', () => ({ getBlock: (type: string) => type === 'generic_webhook' ? genericWebhookConfig : type === 'github_v2' ? multiTriggerConfig - : undefined, + : type === 'mothership' + ? mothershipConfig + : undefined, })) /** @@ -112,6 +126,29 @@ describe('sanitizeForCopilot knowledge tag subblocks', () => { }) }) +describe('sanitizeForCopilot server-only block inputs', () => { + it('omits Sim Chat secret-mount policy while retaining model-visible inputs', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('chat-1', { + type: 'mothership', + name: 'Sim Chat 1', + enabled: true, + subBlocks: { + prompt: { id: 'prompt', type: 'long-input', value: 'Help me' }, + secretScope: { id: 'secretScope', type: 'dropdown', value: 'selected' }, + mountedSecrets: { + id: 'mountedSecrets', + type: 'dropdown', + value: ['OPENAI_API_KEY'], + }, + }, + }) + ) + + expect(result.blocks['chat-1'].inputs).toEqual({ prompt: 'Help me' }) + }) +}) + /** Builds a one-block workflow for webhook-URL synthesis tests. */ function makeSingleBlockWorkflow(blockId: string, block: Record): WorkflowState { return { diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 2d4f09bba40..812dbb62f6f 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -270,11 +270,14 @@ function isToolInput(value: unknown): value is ToolInput { * already handled by `sanitizeWorkflowForSharing`. */ function sanitizeSubBlocks( - subBlocks: BlockState['subBlocks'] + subBlocks: BlockState['subBlocks'], + hiddenIds: ReadonlySet ): Record { const sanitized: Record = {} Object.entries(subBlocks).forEach(([key, subBlock]) => { + if (hiddenIds.has(key)) return + // Skip null/undefined values if (subBlock.value === null || subBlock.value === undefined) { return @@ -569,7 +572,12 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState { inputs = loopInputs } else { // For regular blocks, sanitize subBlocks - inputs = sanitizeSubBlocks(block.subBlocks) + const hiddenIds = new Set( + (getBlock(block.type)?.subBlocks ?? []) + .filter((subBlock) => subBlock.hideFromCopilot) + .map((subBlock) => subBlock.id) + ) + inputs = sanitizeSubBlocks(block.subBlocks, hiddenIds) const webhookUrl = resolveTriggerWebhookUrl(blockId, block) if (webhookUrl) { diff --git a/apps/sim/lib/workflows/schedules/orchestration.test.ts b/apps/sim/lib/workflows/schedules/orchestration.test.ts index b656490043d..771cdd5840c 100644 --- a/apps/sim/lib/workflows/schedules/orchestration.test.ts +++ b/apps/sim/lib/workflows/schedules/orchestration.test.ts @@ -32,12 +32,15 @@ import { performUpdateJob } from '@/lib/workflows/schedules/orchestration' const BASE_JOB = { id: 'job-1', sourceWorkspaceId: 'workspace-1', + sourceUserId: 'user-1', sourceType: 'job', archivedAt: null, timezone: 'UTC', cronExpression: null, jobTitle: 'Nightly task', status: 'disabled', + secretScope: 'all', + mountedSecrets: [], } describe('performUpdateJob', () => { @@ -81,4 +84,50 @@ describe('performUpdateJob', () => { nextRunAt: new Date('2099-01-01T09:00:00Z'), }) }) + + it('denies task content edits from a non-creator without writing', async () => { + queueTableRows(schemaMock.workflowSchedule, [BASE_JOB]) + + const result = await performUpdateJob({ + jobId: 'job-1', + workspaceId: 'workspace-1', + userId: 'workspace-writer', + prompt: 'Changed prompt', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'forbidden' }) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) + + it('allows a non-creator to pause a task', async () => { + queueTableRows(schemaMock.workflowSchedule, [{ ...BASE_JOB, status: 'active' }]) + + const result = await performUpdateJob({ + jobId: 'job-1', + workspaceId: 'workspace-1', + userId: 'workspace-writer', + status: 'paused', + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set.mock.calls[0][0]).toMatchObject({ status: 'disabled' }) + }) + + it('persists a canonical selected secret policy for the creator', async () => { + queueTableRows(schemaMock.workflowSchedule, [BASE_JOB]) + + const result = await performUpdateJob({ + jobId: 'job-1', + workspaceId: 'workspace-1', + userId: 'user-1', + secretScope: 'selected', + mountedSecrets: [' B ', 'A', 'B'], + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set.mock.calls[0][0]).toMatchObject({ + secretScope: 'selected', + mountedSecrets: ['B', 'A'], + }) + }) }) diff --git a/apps/sim/lib/workflows/schedules/orchestration.ts b/apps/sim/lib/workflows/schedules/orchestration.ts index 47da9c5e71d..28a49eec405 100644 --- a/apps/sim/lib/workflows/schedules/orchestration.ts +++ b/apps/sim/lib/workflows/schedules/orchestration.ts @@ -6,6 +6,10 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import type { ScheduleContext } from '@/lib/api/contracts/schedules' +import { + normalizeSecretMountPolicy, + type SecretMountScope, +} from '@/lib/copilot/secret-mount-policy' import { captureServerEvent } from '@/lib/posthog/server' import { computeNextRunAt, @@ -15,7 +19,7 @@ import { const logger = createLogger('ScheduleOrchestration') -type ScheduleErrorCode = 'not_found' | 'validation' | 'internal' +type ScheduleErrorCode = 'not_found' | 'forbidden' | 'validation' | 'internal' interface ActorMetadata { actorName?: string | null @@ -39,6 +43,8 @@ export interface PerformCreateJobParams extends ActorMetadata { endsAt?: string | null /** `@`-mentioned resources / `/`-invoked skills captured with the prompt. */ contexts?: ScheduleContext[] | null + secretScope?: SecretMountScope + mountedSecrets?: string[] sourceChatId?: string | null sourceTaskName?: string | null } @@ -68,6 +74,8 @@ export interface PerformUpdateJobParams extends ActorMetadata { maxRuns?: number | null endsAt?: string | null contexts?: ScheduleContext[] | null + secretScope?: SecretMountScope + mountedSecrets?: string[] } export interface PerformExcludeOccurrenceParams extends ActorMetadata { @@ -192,6 +200,7 @@ export async function performCreateJob( try { const id = generateId() const now = new Date() + const secretMountPolicy = normalizeSecretMountPolicy(params) await db.insert(workflowSchedule).values({ id, workflowId: null, @@ -217,6 +226,8 @@ export async function performCreateJob( sourceTaskName: params.sourceTaskName || null, sourceUserId: params.userId, sourceWorkspaceId: params.workspaceId, + secretScope: secretMountPolicy.secretScope, + mountedSecrets: secretMountPolicy.mountedSecrets, }) const [schedule] = await db @@ -284,6 +295,27 @@ export async function performUpdateJob( if (!job) return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' } + const hasCreatorOnlyUpdate = + params.title !== undefined || + params.prompt !== undefined || + params.cronExpression !== undefined || + params.time !== undefined || + params.timezone !== undefined || + params.lifecycle !== undefined || + params.successCondition !== undefined || + params.maxRuns !== undefined || + params.endsAt !== undefined || + params.contexts !== undefined || + params.secretScope !== undefined || + params.mountedSecrets !== undefined + if (hasCreatorOnlyUpdate && job.sourceUserId !== params.userId) { + return { + success: false, + error: 'Only the task creator can edit this task', + errorCode: 'forbidden', + } + } + const updates: Partial = { updatedAt: new Date() } if (params.title !== undefined) updates.jobTitle = params.title.trim() if (params.prompt !== undefined) updates.prompt = params.prompt.trim() @@ -312,6 +344,14 @@ export async function performUpdateJob( if (params.successCondition !== undefined) updates.successCondition = params.successCondition if (params.maxRuns !== undefined) updates.maxRuns = params.maxRuns if (params.contexts !== undefined) updates.contexts = params.contexts + if (params.secretScope !== undefined || params.mountedSecrets !== undefined) { + const secretMountPolicy = normalizeSecretMountPolicy({ + secretScope: params.secretScope ?? job.secretScope, + mountedSecrets: params.mountedSecrets ?? job.mountedSecrets, + }) + updates.secretScope = secretMountPolicy.secretScope + updates.mountedSecrets = secretMountPolicy.mountedSecrets + } const effectiveStatus = updates.status ?? job.status let endsAt: Date | null = job.endsAt diff --git a/apps/sim/lib/workflows/subblocks/options.ts b/apps/sim/lib/workflows/subblocks/options.ts index 8f5baab9b33..bf97f149a21 100644 --- a/apps/sim/lib/workflows/subblocks/options.ts +++ b/apps/sim/lib/workflows/subblocks/options.ts @@ -1,7 +1,13 @@ +import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' import { fetchWorkspaceEnvironment } from '@/lib/environment/api' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { environmentKeys, WORKSPACE_ENVIRONMENT_STALE_TIME } from '@/hooks/queries/environment' import { getSandboxListQueryOptions, type SandboxListResponse } from '@/hooks/queries/sandboxes' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { + fetchWorkspaceCredentialList, + WORKSPACE_CREDENTIAL_LIST_STALE_TIME, +} from '@/hooks/queries/utils/fetch-workspace-credentials' import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -70,6 +76,21 @@ export async function fetchWorkspaceSecretNameOptions(): Promise ({ id: name, label: name })) } +/** Loads only secret names the current actor may mount as plaintext into Copilot code. */ +export async function fetchWorkspaceRawSecretNameOptions(): Promise { + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return [] + + const credentials = await getQueryClient().fetchQuery({ + queryKey: workspaceCredentialKeys.list(workspaceId), + queryFn: ({ signal }: { signal?: AbortSignal }) => + fetchWorkspaceCredentialList(workspaceId, signal), + staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, + }) + + return selectRawMountableSecretNames(credentials).map((name) => ({ id: name, label: name })) +} + /** * Labels a sandbox for the picker. The name is what identifies it, so that is all * the label carries by default — the block's own list is already scoped to one diff --git a/apps/sim/public/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/cover.jpg b/apps/sim/public/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/cover.jpg new file mode 100644 index 00000000000..2122a0d6261 Binary files /dev/null and b/apps/sim/public/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/cover.jpg differ diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index f39614c6987..ef27aa74c40 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -329,6 +329,16 @@ export class Serializer { enabled: block.enabled, } + const privateInputIds = new Set() + for (const subBlock of blockConfig.subBlocks) { + if (!subBlock.hideFromCopilot) continue + privateInputIds.add(subBlock.id) + if (subBlock.canonicalParamId) privateInputIds.add(subBlock.canonicalParamId) + } + if (privateInputIds.size > 0) { + serialized.privateInputIds = [...privateInputIds] + } + if (block.data?.canonicalModes) { serialized.canonicalModes = block.data.canonicalModes as Record } diff --git a/apps/sim/serializer/private-inputs.test.ts b/apps/sim/serializer/private-inputs.test.ts new file mode 100644 index 00000000000..d97e0e6d109 --- /dev/null +++ b/apps/sim/serializer/private-inputs.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BlockState } from '@/stores/workflows/workflow/types' + +const { mockGetBlock } = vi.hoisted(() => ({ + mockGetBlock: vi.fn(), +})) + +vi.mock('@/blocks', () => ({ + getBlock: mockGetBlock, +})) + +vi.mock('@/tools/metadata', () => ({ + getToolParams: vi.fn(() => undefined), +})) + +import { Serializer } from '@/serializer' + +describe('Serializer private inputs', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBlock.mockReturnValue({ + name: 'Private lifecycle block', + description: 'Test block', + category: 'blocks', + bgColor: '#000000', + tools: { + access: ['private_lifecycle'], + config: { tool: () => 'private_lifecycle' }, + }, + subBlocks: [ + { id: 'prompt', type: 'long-input' }, + { id: 'secretScope', type: 'dropdown', hideFromCopilot: true }, + { + id: 'mountedSecretsAdvanced', + canonicalParamId: 'mountedSecrets', + type: 'dropdown', + hideFromCopilot: true, + }, + ], + inputs: { + prompt: { type: 'string' }, + secretScope: { type: 'string' }, + mountedSecrets: { type: 'json' }, + }, + outputs: {}, + }) + }) + + it('derives executor-private input ids from block metadata', () => { + const block = { + id: 'block-1', + type: 'private_lifecycle', + name: 'Private lifecycle block', + position: { x: 0, y: 0 }, + subBlocks: { + prompt: { id: 'prompt', type: 'long-input', value: 'Run the task' }, + secretScope: { id: 'secretScope', type: 'dropdown', value: 'selected' }, + mountedSecretsAdvanced: { + id: 'mountedSecretsAdvanced', + type: 'dropdown', + value: ['API_KEY'], + }, + }, + outputs: {}, + enabled: true, + } as BlockState + + const serialized = new Serializer().serializeWorkflow({ [block.id]: block }, [], {}) + + expect(serialized.blocks[0].privateInputIds).toEqual([ + 'secretScope', + 'mountedSecretsAdvanced', + 'mountedSecrets', + ]) + }) +}) diff --git a/apps/sim/serializer/types.ts b/apps/sim/serializer/types.ts index 8d7bc56e4ed..2fb123ecee9 100644 --- a/apps/sim/serializer/types.ts +++ b/apps/sim/serializer/types.ts @@ -40,6 +40,8 @@ export interface SerializedBlock { enabled: boolean /** Canonical mode overrides from block.data (used by agent handler for tool param resolution) */ canonicalModes?: Record + /** Server-only lifecycle input ids omitted from execution-log projections. */ + privateInputIds?: string[] } export interface SerializedLoop { diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index d3895b9c367..579dedc54ff 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -26,6 +26,7 @@ import { import { sleep } from '@sim/utils/helpers' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { ANONYMOUS_SECRET_TRACE_REPLACEMENT, ResolvedSecretTraceRegistry, @@ -700,12 +701,97 @@ describe('executeTool Function', () => { expect(new Headers(requestInit?.headers).get('x-sim-request-private-tool-metadata')).toBe( 'resolved-secret-names-v1' ) - expect(result.output).not.toHaveProperty('__resolvedSecretNames') + expect(result.output).toEqual({ + success: true, + output: { result: 'secret-value', stdout: '' }, + }) expect(registry.getActiveMatches()).toEqual([ { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, ]) }) + it('fails concurrent projection closed while custom-tool provenance is pending', async () => { + const secret = 'custom-tool-secret-value' + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'API_KEY', + plaintext: secret, + encryptedValue: 'encrypted-value', + }, + ]) + mockGetToolAsync.mockResolvedValueOnce({ + id: 'custom_pending-provenance', + name: 'Pending provenance custom tool', + description: 'Tests late provenance activation', + version: '1.0.0', + params: {}, + request: { + url: '/api/function/execute', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: () => ({ code: 'return {{API_KEY}}', envVars: { API_KEY: secret } }), + }, + transformResponse: async (response: Response) => { + const data = await response.json() + return { success: true, output: data.output } + }, + }) + + let resolveRequest!: (response: Response) => void + let markRequestStarted!: () => void + const requestStarted = new Promise((resolve) => { + markRequestStarted = resolve + }) + global.fetch = Object.assign( + vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveRequest = resolve + markRequestStarted() + }) + ), + { preconnect: vi.fn() } + ) as typeof fetch + + const execution = executeTool( + 'custom_pending-provenance', + { envVars: { API_KEY: secret } }, + { + executionContext: createToolExecutionContext(), + resolvedSecretTraceRegistry: registry, + } + ) + await requestStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).not.toHaveProperty('output') + + resolveRequest( + new Response( + JSON.stringify({ + success: true, + output: { result: secret }, + __resolvedSecretNames: ['API_KEY'], + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-sim-private-tool-metadata': 'resolved-secret-names-v1', + }, + } + ) + ) + + await expect(execution).resolves.toMatchObject({ success: true }) + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).toMatchObject({ output: { result: '{{API_KEY}}' } }) + }) + it('keeps the Function result unchanged when requested provenance is missing', async () => { const registry = new ResolvedSecretTraceRegistry() global.fetch = Object.assign( @@ -1778,6 +1864,53 @@ describe('Copilot Env Variable Reference Resolution', () => { expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token') }) + it('fails concurrent projection closed while a user-only secret reference is resolving', async () => { + const secret = 'sntrys_real_token' + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'SENTRY_AUTH_TOKEN', + plaintext: secret, + encryptedValue: 'encrypted-token', + }, + ]) + let resolveEnvironment!: (variables: Record) => void + let markResolutionStarted!: () => void + const resolutionStarted = new Promise((resolve) => { + markResolutionStarted = resolve + }) + mockGetEffectiveDecryptedEnv.mockImplementationOnce( + () => + new Promise>((resolve) => { + resolveEnvironment = resolve + markResolutionStarted() + }) + ) + mockJsonFetch() + + const execution = executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}' }, + { + executionContext: copilotContext(), + resolvedSecretTraceRegistry: registry, + } + ) + await resolutionStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).not.toHaveProperty('output') + + resolveEnvironment({ SENTRY_AUTH_TOKEN: secret }) + await expect(execution).resolves.toMatchObject({ success: true }) + + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).toMatchObject({ output: { result: '{{SENTRY_AUTH_TOKEN}}' } }) + }) + it('trims whitespace inside the braces like the executor resolver', async () => { const fetchMock = mockJsonFetch() @@ -2251,6 +2384,74 @@ describe('MCP Tool Execution', () => { ]) }) + it('fails concurrent projection closed while MCP provenance is pending', async () => { + const secret = 'mcp-secret-value' + const registry = new ResolvedSecretTraceRegistry([], { + userId: 'test-user', + workspaceId: 'workspace-456', + }) + encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ decrypted: secret }) + + let resolveRequest!: (response: Response) => void + let markRequestStarted!: () => void + const requestStarted = new Promise((resolve) => { + markRequestStarted = resolve + }) + global.fetch = Object.assign( + vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveRequest = resolve + markRequestStarted() + }) + ), + { preconnect: vi.fn() } + ) as typeof fetch + + const execution = executeTool( + 'mcp-123-list_files', + { path: '/test' }, + { + executionContext: createToolExecutionContext(), + resolvedSecretTraceRegistry: registry, + } + ) + await requestStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { value: secret } }, registry) + ).not.toHaveProperty('output') + + resolveRequest( + new Response( + JSON.stringify({ + success: true, + data: { output: { content: [{ type: 'text', text: secret }] } }, + __resolvedSecretTraceProvenance: { + version: 1, + complete: true, + entries: [{ name: 'MCP_TOKEN', encryptedValue: 'encrypted-token' }], + scope: { userId: 'test-user', workspaceId: 'workspace-456' }, + }, + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-sim-private-tool-metadata': 'resolved-secret-provenance-v1', + }, + } + ) + ) + + await expect(execution).resolves.toMatchObject({ success: true }) + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { value: secret } }, registry) + ).toMatchObject({ output: { value: '{{MCP_TOKEN}}' } }) + }) + it('rejects unmarked MCP provenance instead of trusting a response body field', async () => { const registry = new ResolvedSecretTraceRegistry() global.fetch = Object.assign( diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 36f9a0eeb28..3d20a4faaaf 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -243,28 +243,33 @@ async function resolveCopilotEnvReferences( ) } - const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') - const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) - - for (const { paramId, value } of pending) { - const missingKeys: string[] = [] - const resolved = resolveEnvVarReferences(value, envVars, { - allowEmbedded: false, - missingKeys, - onResolved: (name, resolvedValue) => { - resolvedSecretTraceRegistry?.recordResolved(name, resolvedValue) - }, - }) - if (missingKeys.length > 0) { - const scopeHint = scope.workspaceId - ? '' - : ' (no workspace context — only personal variables are available here)' - throw new Error( - `Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found${scopeHint}. ` + - `Check environment/variables.json for available variable names.` - ) + const completePendingActivation = resolvedSecretTraceRegistry?.beginPendingActivation() + try { + const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') + const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) + + for (const { paramId, value } of pending) { + const missingKeys: string[] = [] + const resolved = resolveEnvVarReferences(value, envVars, { + allowEmbedded: false, + missingKeys, + onResolved: (name, resolvedValue) => { + resolvedSecretTraceRegistry?.recordResolved(name, resolvedValue) + }, + }) + if (missingKeys.length > 0) { + const scopeHint = scope.workspaceId + ? '' + : ' (no workspace context — only personal variables are available here)' + throw new Error( + `Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found${scopeHint}. ` + + `Check environment/variables.json for available variable names.` + ) + } + params[paramId] = resolved as string } - params[paramId] = resolved as string + } finally { + completePendingActivation?.() } } @@ -1246,6 +1251,7 @@ export async function executeTool( // Hoisted so the outer catch can attribute a thrown failure to the chosen key. let hostedKeyForMetrics: { provider: string; tool: string; key: string } | undefined + let completePendingSecretActivation: (() => void) | undefined try { let tool: ToolConfig | undefined @@ -1271,6 +1277,10 @@ export async function executeTool( ? RESOLVED_SECRET_NAMES_METADATA_V1 : undefined + if (resolvedSecretTraceRegistry && (privateToolMetadataType || toolKind === 'mcp')) { + completePendingSecretActivation = resolvedSecretTraceRegistry.beginPendingActivation() + } + // Runs for ALL tools (not just kinded ones) so the per-tool `deniedTools` // denylist is enforced alongside the existing mcp/custom/skill gates. if (scope.userId && scope.workspaceId) { @@ -1773,6 +1783,8 @@ export async function executeTool( duration, }, } + } finally { + completePendingSecretActivation?.() } } diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index 2cc7c6e28dc..5f1a45cca2e 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -65,6 +65,12 @@ export default defineConfig({ '@earendil-works/pi-ai', '@earendil-works/pi-coding-agent', 'cpu-features', + // `@e2b/code-interpreter` copies `e2b`'s members onto its exports at runtime, so + // bundling drops every name a static analyzer cannot see — `Template` among them. + // Same reason `next.config.ts` keeps these in `serverExternalPackages`. + 'e2b', + '@e2b/code-interpreter', + '@daytona/sdk', ], extensions: [ syncEnvVars(() => [{ name: 'DB_APP_NAME', value: 'sim-trigger' }]), @@ -84,6 +90,8 @@ export default defineConfig({ '@react-email/render', '@earendil-works/pi-ai', '@earendil-works/pi-coding-agent', + '@e2b/code-interpreter', + '@daytona/sdk', ], }), ], diff --git a/packages/db/migrations/0280_great_riptide.sql b/packages/db/migrations/0280_great_riptide.sql new file mode 100644 index 00000000000..e895e1ec040 --- /dev/null +++ b/packages/db/migrations/0280_great_riptide.sql @@ -0,0 +1,5 @@ +-- migration-safe: additive columns use non-null defaults, so old and new app versions can read and write these rows throughout the deploy. +ALTER TABLE "workflow_schedule" ADD COLUMN "secret_scope" text DEFAULT 'all' NOT NULL;--> statement-breakpoint +ALTER TABLE "workflow_schedule" ADD COLUMN "mounted_secrets" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace" ADD COLUMN "inbox_secret_scope" text DEFAULT 'all' NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace" ADD COLUMN "inbox_mounted_secrets" jsonb DEFAULT '[]'::jsonb NOT NULL; diff --git a/packages/db/migrations/meta/0280_snapshot.json b/packages/db/migrations/meta/0280_snapshot.json new file mode 100644 index 00000000000..0a4dee2c63f --- /dev/null +++ b/packages/db/migrations/meta/0280_snapshot.json @@ -0,0 +1,18398 @@ +{ + "id": "d1c2701c-3233-4ce4-9bf1-3ee3065c8e9b", + "prevId": "4b619949-ee98-4251-b621-5f37a9fa23a3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 30be907c184..64de524fd6e 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1954,6 +1954,13 @@ "when": 1785542556609, "tag": "0279_collab_doc_state_and_content_version", "breakpoints": true + }, + { + "idx": 280, + "version": "7", + "when": 1785640502989, + "tag": "0280_great_riptide", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 87805620307..e29c6825f91 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -756,6 +756,8 @@ export const workflowSchedule = pgTable( sourceWorkspaceId: text('source_workspace_id').references(() => workspace.id, { onDelete: 'cascade', }), + secretScope: text('secret_scope').notNull().default('all'), + mountedSecrets: jsonb('mounted_secrets').$type().notNull().default([]), jobHistory: jsonb('job_history').$type>(), /** `@`-mentioned resources / `/`-invoked skills captured with the prompt, resolved into the agent run at fire time. */ contexts: jsonb('contexts').$type>>(), @@ -1595,6 +1597,8 @@ export const workspace = pgTable( inboxEnabled: boolean('inbox_enabled').notNull().default(false), inboxAddress: text('inbox_address'), inboxProviderId: text('inbox_provider_id'), + inboxSecretScope: text('inbox_secret_scope').notNull().default('all'), + inboxMountedSecrets: jsonb('inbox_mounted_secrets').$type().notNull().default([]), archivedAt: timestamp('archived_at'), organizationAssignedAt: timestamp('organization_assigned_at'), forkedFromWorkspaceId: text('forked_from_workspace_id').references( diff --git a/packages/testing/src/mocks/logging-session.mock.ts b/packages/testing/src/mocks/logging-session.mock.ts index 0f951db2484..3cefc0eb2e0 100644 --- a/packages/testing/src/mocks/logging-session.mock.ts +++ b/packages/testing/src/mocks/logging-session.mock.ts @@ -5,7 +5,8 @@ import { vi } from 'vitest' * `@/lib/logs/execution/logging-session`. Every instance method is backed by a * shared `vi.fn()` so tests that construct multiple sessions observe identical * mock state. `mockSafeStart` defaults to `true` because callers branch on the - * boolean result. All other methods resolve to `undefined`. + * boolean result. Projection methods return their input; other methods resolve + * to `undefined`. * * @example * ```ts @@ -24,6 +25,10 @@ export const loggingSessionMockFns = { mockSafeStart: vi.fn().mockResolvedValue(true), mockWaitForCompletion: vi.fn().mockResolvedValue(undefined), mockWaitForPostExecution: vi.fn().mockResolvedValue(undefined), + mockSetTrustedExecutionCorrelation: vi.fn(), + mockProjectBlockLogsForDisplay: vi.fn(async (logs: unknown) => logs), + mockProjectDisplayContent: vi.fn(async (content: unknown) => content), + mockProjectLiveDisplayText: vi.fn(async (_field: string, value: string) => ({ value })), mockSafeComplete: vi.fn().mockResolvedValue(undefined), mockSafeCompleteWithError: vi.fn().mockResolvedValue(undefined), mockSafeCompleteWithCancellation: vi.fn().mockResolvedValue(undefined), @@ -47,6 +52,10 @@ function buildLoggingSessionInstance() { safeStart: loggingSessionMockFns.mockSafeStart, waitForCompletion: loggingSessionMockFns.mockWaitForCompletion, waitForPostExecution: loggingSessionMockFns.mockWaitForPostExecution, + setTrustedExecutionCorrelation: loggingSessionMockFns.mockSetTrustedExecutionCorrelation, + projectBlockLogsForDisplay: loggingSessionMockFns.mockProjectBlockLogsForDisplay, + projectDisplayContent: loggingSessionMockFns.mockProjectDisplayContent, + projectLiveDisplayText: loggingSessionMockFns.mockProjectLiveDisplayText, safeComplete: loggingSessionMockFns.mockSafeComplete, safeCompleteWithError: loggingSessionMockFns.mockSafeCompleteWithError, safeCompleteWithCancellation: loggingSessionMockFns.mockSafeCompleteWithCancellation,