diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index 4feb7b91318..7632b0bea66 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -17,7 +17,7 @@ concurrency: jobs: e2e: name: E2E (${{ matrix.electron }}) - runs-on: macos-14 + runs-on: macos-26 strategy: fail-fast: false matrix: @@ -58,7 +58,7 @@ jobs: package-smoke: name: Unsigned package smoke - runs-on: macos-14 + runs-on: macos-26 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index d8a9e650d89..17d7e298a5d 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -49,7 +49,7 @@ permissions: jobs: build-sign-notarize: name: Build, Sign, Notarize - runs-on: macos-14 + runs-on: macos-26 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -167,8 +167,8 @@ jobs: if: ${{ inputs.sign }} run: | DMG="$(ls apps/desktop/release/*.dmg | head -1)" - xcrun stapler validate "$DMG" hdiutil attach "$DMG" -mountpoint /tmp/sim-dmg -nobrowse -quiet + xcrun stapler validate /tmp/sim-dmg/*.app spctl --assess --type execute --verbose /tmp/sim-dmg/*.app codesign --verify --deep --strict /tmp/sim-dmg/*.app hdiutil detach /tmp/sim-dmg -quiet diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml new file mode 100644 index 00000000000..48f7b0f557f --- /dev/null +++ b/.github/workflows/publish-sim-cli.yml @@ -0,0 +1,112 @@ +name: Publish Sim API CLI Package + +on: + push: + branches: [main, staging, dev] + paths: + - 'packages/sim-cli/**' + +permissions: + contents: read + +concurrency: + group: publish-sim-cli-${{ github.ref }} + cancel-in-progress: true + +jobs: + publish-npm: + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Cache Bun dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: | + ~/.bun/install/cache + node_modules + **/node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Verify initial package exists + run: bun pm view @simai/cli@preview name + + - name: Run tests + working-directory: packages/sim-cli + run: bun run test + + - name: Type-check package + working-directory: packages/sim-cli + run: bun run type-check + + - name: Build package + working-directory: packages/sim-cli + run: bun run build + + - name: Resolve release channel + id: release + working-directory: packages/sim-cli + env: + BRANCH: ${{ github.ref_name }} + run: | + BASE_VERSION="$(bun -p "require('./package.json').version")" + if [[ ! "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Package version must be a stable X.Y.Z base, got '$BASE_VERSION'." >&2 + exit 1 + fi + + case "$BRANCH" in + dev) + VERSION="${BASE_VERSION}-dev.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" + TAG="dev" + ;; + staging) + VERSION="${BASE_VERSION}-preview.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" + TAG="preview" + ;; + main) + VERSION="$BASE_VERSION" + TAG="latest" + ;; + *) + echo "Unsupported release branch '$BRANCH'." >&2 + exit 1 + ;; + esac + + bun pm pkg set "version=$VERSION" + RESOLVED_VERSION="$(bun -p "require('./package.json').version")" + if [ "$RESOLVED_VERSION" != "$VERSION" ]; then + echo "Version injection mismatch: wanted '$VERSION', got '$RESOLVED_VERSION'." >&2 + exit 1 + fi + + { + echo "version=$VERSION" + echo "tag=$TAG" + } >> "$GITHUB_OUTPUT" + + - name: Publish to npm + working-directory: packages/sim-cli + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TAG: ${{ steps.release.outputs.tag }} + run: bun publish --access public --tag "$NPM_TAG" --no-save + + - name: Summarize release + env: + VERSION: ${{ steps.release.outputs.version }} + NPM_TAG: ${{ steps.release.outputs.tag }} + run: echo "Published @simai/cli@$VERSION with the '$NPM_TAG' tag." diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 16a2dbb25bf..0b4a4d0fe51 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -129,6 +129,13 @@ jobs: - name: Desktop bridge contract audit run: bun run check:desktop-bridge + # The CLI's view of the v2 API is generated from the same Zod contracts + # the routes validate against, so a contract change that skips + # `generate:cli-api` would ship a client describing endpoints the server + # no longer has. + - name: Sim CLI API generation up to date + run: bun run check:cli-api + # Complements the bridge audit above, which compares against a snapshot # this same PR is allowed to regenerate. This one derives every fact from # the source both sides execute, so it has no such blind spot. diff --git a/apps/desktop/build/dmg-background.png b/apps/desktop/build/dmg-background.png new file mode 100644 index 00000000000..a79f4414535 Binary files /dev/null and b/apps/desktop/build/dmg-background.png differ diff --git a/apps/desktop/build/dmg-background@2x.png b/apps/desktop/build/dmg-background@2x.png new file mode 100644 index 00000000000..2b0f9170246 Binary files /dev/null and b/apps/desktop/build/dmg-background@2x.png differ diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index c13ceaaa30c..c8f3d603c6e 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -60,6 +60,22 @@ mac: dmg: sign: false + title: ${productName} + # Finder uses the image dimensions as the installer window dimensions. The + # @2x companion is detected automatically and keeps the mountain artwork and + # install arrow sharp on Retina displays. + background: build/dmg-background.png + iconSize: 96 + iconTextSize: 13 + # Keep both icon centers inside the compact 660x420 Finder canvas. + contents: + - x: 165 + y: 210 + type: file + - x: 495 + y: 210 + type: link + path: /Applications # node-pty ships pure N-API prebuilds, which are ABI-stable across Node and # Electron versions, so there is nothing to rebuild against Electron's ABI. diff --git a/apps/desktop/src/main/desktop-settings.test.ts b/apps/desktop/src/main/desktop-settings.test.ts index 25c137aa8ca..b7849a57558 100644 --- a/apps/desktop/src/main/desktop-settings.test.ts +++ b/apps/desktop/src/main/desktop-settings.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { TERMINAL_DARK_THEME } from '@sim/desktop-bridge' +import { TERMINAL_DARK_THEME, TERMINAL_LIGHT_THEME } from '@sim/desktop-bridge' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -15,6 +15,14 @@ const IMPORTED_PALETTE = { ...TERMINAL_DARK_THEME, background: '#101010', } +const IMPORTED_LIGHT_PALETTE = { + ...TERMINAL_LIGHT_THEME, + background: '#fafafa', +} +const IMPORTED_DARK_PALETTE = { + ...TERMINAL_DARK_THEME, + background: '#202020', +} function makeService() { const config = createConfigStore( @@ -191,7 +199,7 @@ describe('desktop settings service', () => { expect(preferences?.browserDownloadDirectory).toBe('/tmp/custom-downloads') }) - it('caches and selects a Terminal or iTerm2 profile', () => { + it('persists a Terminal or iTerm2 profile with appearance-specific palettes', () => { const { config, service } = makeService() const preferences = service.selectTerminalProfile({ @@ -199,6 +207,8 @@ describe('desktop settings service', () => { name: 'Ocean', source: 'iterm2', palette: IMPORTED_PALETTE, + lightPalette: IMPORTED_LIGHT_PALETTE, + darkPalette: IMPORTED_DARK_PALETTE, }) expect(config.get('terminalTheme')).toEqual({ @@ -206,6 +216,8 @@ describe('desktop settings service', () => { name: 'Ocean', source: 'iterm2', palette: IMPORTED_PALETTE, + lightPalette: IMPORTED_LIGHT_PALETTE, + darkPalette: IMPORTED_DARK_PALETTE, }) expect(preferences).toMatchObject({ terminalTheme: { id: 'iterm2:ocean', name: 'Ocean' }, diff --git a/apps/desktop/src/main/desktop-settings.ts b/apps/desktop/src/main/desktop-settings.ts index aeaef5a0852..f84b364b40b 100644 --- a/apps/desktop/src/main/desktop-settings.ts +++ b/apps/desktop/src/main/desktop-settings.ts @@ -1,5 +1,6 @@ import { isAbsolute } from 'node:path' import { + cloneTerminalSelectedProfile, type DesktopAppearanceTheme, type DesktopNotificationPayload, type DesktopPreferenceKey, @@ -177,12 +178,7 @@ export function createDesktopSettingsService( return read() }, selectTerminalProfile(profile) { - deps.config.set('terminalTheme', { - id: profile.id, - name: profile.name, - source: profile.source, - palette: { ...profile.palette }, - }) + deps.config.set('terminalTheme', cloneTerminalSelectedProfile(profile)) deps.config.flush() return read() }, diff --git a/apps/desktop/src/main/terminal-themes.test.ts b/apps/desktop/src/main/terminal-themes.test.ts index 55538455b0c..4887ea43342 100644 --- a/apps/desktop/src/main/terminal-themes.test.ts +++ b/apps/desktop/src/main/terminal-themes.test.ts @@ -1,4 +1,4 @@ -import { TERMINAL_DARK_THEME } from '@sim/desktop-bridge' +import { TERMINAL_DARK_THEME, TERMINAL_LIGHT_THEME } from '@sim/desktop-bridge' import { describe, expect, it } from 'vitest' import { parseTerminalThemeProfiles } from '@/main/terminal-themes' @@ -6,6 +6,10 @@ const PALETTE = { ...TERMINAL_DARK_THEME, background: '#101010', } +const LIGHT_PALETTE = { + ...TERMINAL_LIGHT_THEME, + background: '#fafafa', +} function profile(id: string, overrides: Record = {}) { return { @@ -22,10 +26,22 @@ describe('parseTerminalThemeProfiles', () => { expect(parseTerminalThemeProfiles([profile('iterm2:ocean')])).toEqual([profile('iterm2:ocean')]) }) + it('preserves separate iTerm2 light and dark palettes', () => { + const separateProfile = profile('iterm2:ocean', { + lightPalette: LIGHT_PALETTE, + darkPalette: PALETTE, + }) + + expect(parseTerminalThemeProfiles([separateProfile])).toEqual([separateProfile]) + }) + it('drops malformed colors and unsupported applications', () => { expect( parseTerminalThemeProfiles([ profile('bad-color', { palette: { ...PALETTE, background: 'rgb(0, 0, 0)' } }), + profile('bad-mode-color', { + lightPalette: { ...LIGHT_PALETTE, foreground: 'white' }, + }), profile('bad-source', { source: 'warp' }), ]) ).toEqual([]) diff --git a/apps/desktop/src/main/terminal-themes.ts b/apps/desktop/src/main/terminal-themes.ts index eecbe2e631c..dfd26cb60f5 100644 --- a/apps/desktop/src/main/terminal-themes.ts +++ b/apps/desktop/src/main/terminal-themes.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { + cloneTerminalSelectedProfile, isTerminalSelectedProfile, TERMINAL_DARK_THEME, TERMINAL_LIGHT_THEME, @@ -86,21 +87,25 @@ function terminalPalette(profile) { return palette } -function itermPalette(profile) { - const background = dictionaryColor(profile['Background Color'], LIGHT_THEME.background) +function itermColor(profile, key, suffix, fallback) { + return dictionaryColor(profile[key + suffix], dictionaryColor(profile[key], fallback)) +} + +function itermPalette(profile, suffix) { + const background = itermColor(profile, 'Background Color', suffix, LIGHT_THEME.background) const dark = isDark(background) const fallback = dark ? DARK_THEME : LIGHT_THEME const palette = { background: background, - foreground: dictionaryColor(profile['Foreground Color'], fallback.foreground), - cursor: dictionaryColor(profile['Cursor Color'], fallback.cursor), - cursorAccent: dictionaryColor(profile['Cursor Text Color'], background), - selectionBackground: dictionaryColor(profile['Selection Color'], fallback.selectionBackground), - selectionForeground: dictionaryColor(profile['Selected Text Color'], fallback.foreground) + foreground: itermColor(profile, 'Foreground Color', suffix, fallback.foreground), + cursor: itermColor(profile, 'Cursor Color', suffix, fallback.cursor), + cursorAccent: itermColor(profile, 'Cursor Text Color', suffix, background), + selectionBackground: itermColor(profile, 'Selection Color', suffix, fallback.selectionBackground), + selectionForeground: itermColor(profile, 'Selected Text Color', suffix, fallback.foreground) } for (let index = 0; index < PALETTE_KEYS.length; index += 1) { const key = PALETTE_KEYS[index] - palette[key] = dictionaryColor(profile['Ansi ' + index + ' Color'], fallback[key]) + palette[key] = itermColor(profile, 'Ansi ' + index + ' Color', suffix, fallback[key]) } return palette } @@ -131,12 +136,18 @@ try { const guid = String(profile.Guid || '') const name = String(profile.Name || '') if (!guid || !name) continue - profiles.push({ + const result = { id: 'iterm2:' + encodeURIComponent(guid), name: name, source: 'iterm2', - palette: itermPalette(profile) - }) + palette: itermPalette(profile, '') + } + const separateColors = profile['Use Separate Colors for Light and Dark Mode'] + if (separateColors === true || separateColors === 1) { + result.lightPalette = itermPalette(profile, ' (Light)') + result.darkPalette = itermPalette(profile, ' (Dark)') + } + profiles.push(result) } } catch (_) {} @@ -151,12 +162,7 @@ export function parseTerminalThemeProfiles(value: unknown): TerminalThemeProfile for (const candidate of value) { if (!isTerminalSelectedProfile(candidate) || seen.has(candidate.id)) continue seen.add(candidate.id) - profiles.push({ - id: candidate.id, - name: candidate.name, - source: candidate.source, - palette: { ...candidate.palette }, - }) + profiles.push(cloneTerminalSelectedProfile(candidate)) } return profiles.sort( (left, right) => left.source.localeCompare(right.source) || left.name.localeCompare(right.name) @@ -180,9 +186,8 @@ async function readTerminalThemeProfiles(): Promise { let cachedProfiles: TerminalThemeProfile[] | null = null let profileLoad: Promise | null = null -/** Reads Terminal.app and iTerm2 profiles once per desktop process. */ +/** Reads current Terminal.app and iTerm2 profiles, coalescing concurrent requests. */ export async function listTerminalThemeProfiles(): Promise { - if (cachedProfiles) return cachedProfiles profileLoad ??= readTerminalThemeProfiles() .then((profiles) => { cachedProfiles = profiles diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 0ba00f9fdca..b2ad5db2670 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -25,6 +25,7 @@ import { isNewerVersion, parseSemver, resolveUpdateChannel, + updateCheckIntervalMs, } from '@/main/updater' describe('resolveUpdateChannel', () => { @@ -39,6 +40,17 @@ describe('resolveUpdateChannel', () => { }) }) +describe('updateCheckIntervalMs', () => { + it('checks dev and staging builds every five minutes', () => { + expect(updateCheckIntervalMs('1.2.3-alpha.2')).toBe(5 * 60 * 1000) + expect(updateCheckIntervalMs('1.2.3-beta.1')).toBe(5 * 60 * 1000) + }) + + it('checks production builds every thirty minutes', () => { + expect(updateCheckIntervalMs('1.2.3')).toBe(30 * 60 * 1000) + }) +}) + describe('parseSemver', () => { it('parses plain and v-prefixed versions', () => { expect(parseSemver('1.2.3')).toEqual({ major: 1, minor: 2, patch: 3, prerelease: '' }) @@ -262,6 +274,22 @@ describe('initUpdater state machine', () => { vi.mocked(app.getVersion).mockReturnValue('1.0.0') } }) + + it.each([ + ['1.0.1-alpha.7', 5 * 60 * 1000], + ['1.0.1-beta.7', 5 * 60 * 1000], + ['1.0.1', 30 * 60 * 1000], + ])('schedules %s update polling every %i milliseconds', async (version, interval) => { + vi.mocked(app.getVersion).mockReturnValue(version) + const intervalSpy = vi.spyOn(globalThis, 'setInterval') + try { + await createUpdater({ feedAvailable: true }) + expect(intervalSpy).toHaveBeenCalledWith(expect.any(Function), interval) + } finally { + intervalSpy.mockRestore() + vi.mocked(app.getVersion).mockReturnValue('1.0.0') + } + }) }) function manifest(version: string): string { diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 711875cfdf6..c35d5e9eaec 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -10,7 +10,8 @@ import type { EventRecorder } from '@/main/observability' const logger = createLogger('DesktopUpdater') const INITIAL_CHECK_DELAY_MS = 10_000 -const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000 +const PRERELEASE_CHECK_INTERVAL_MS = 5 * 60 * 1000 +const STABLE_CHECK_INTERVAL_MS = 30 * 60 * 1000 export type UpdateChannel = 'latest' | 'beta' | 'alpha' @@ -72,6 +73,13 @@ export function resolveUpdateChannel(version: string): UpdateChannel { return 'latest' } +/** Dev/staging shells poll rapidly; production shells use a quieter cadence. */ +export function updateCheckIntervalMs(version: string): number { + return resolveUpdateChannel(version) === 'latest' + ? STABLE_CHECK_INTERVAL_MS + : PRERELEASE_CHECK_INTERVAL_MS +} + interface ParsedSemver { major: number minor: number @@ -263,7 +271,8 @@ interface UpdateEngine { /** * Keeps installed shells current against the per-environment update feed: - * checks on launch and every four hours, and mirrors pipeline state to the + * checks on launch, then every five minutes for dev/staging builds or every + * thirty minutes for production builds, and mirrors pipeline state to the * renderer for the settings update UI and the minimum-shell-version gate. * * Developer-ID-signed builds use electron-updater (background download, @@ -528,7 +537,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { } const check = () => engine?.check() setTimeout(check, INITIAL_CHECK_DELAY_MS) - setInterval(check, CHECK_INTERVAL_MS) + setInterval(check, updateCheckIntervalMs(currentVersion)) }) return { diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index 2cea955c88e..8f9def2750a 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -1,8 +1,8 @@ { "openapi": "3.1.0", "info": { - "title": "Sim API — Execution & Usage", - "description": "Run workflows, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", + "title": "Sim API — Execution, Chat & Usage", + "description": "Run workflows, chat with a workspace, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", "version": "1.0.0", "contact": { "name": "Sim Support", @@ -36,6 +36,14 @@ { "name": "Billing", "description": "Inspect billing status and credit-denominated ledger events" + }, + { + "name": "Chat", + "description": "Chat with a workspace through Mothership" + }, + { + "name": "Workspaces", + "description": "Resolve workspace metadata available to the authenticated credential" } ], "security": [ @@ -904,118 +912,988 @@ "message": "Resume execution started." } } - } + } + } + } + }, + "202": { + "description": "Resume execution has been queued for asynchronous processing. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + }, + "example": { + "success": true, + "async": true, + "jobId": "job_4a3b2c1d0e", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution queued", + "statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "503": { + "description": "Failed to queue the resume execution. Retry the request.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message." + } + } + } + } + } + } + } + } + }, + "/api/users/me/usage-limits": { + "get": { + "operationId": "getUsageLimits", + "summary": "Get Usage Limits", + "description": "Retrieve your current usage spending and storage consumption for the billing period.", + "tags": ["Usage"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/users/me/usage-limits\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "Current usage and storage information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageLimits" + }, + "example": { + "success": true, + "usage": { + "currentPeriodCost": 12.5, + "limit": 100, + "plan": "pro" + }, + "storage": { + "usedBytes": 5242880, + "limitBytes": 1073741824, + "percentUsed": 0.49 + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "parameters": [] + } + }, + "/api/v2/workspaces/{workspaceId}": { + "get": { + "operationId": "getWorkspace", + "summary": "Get Workspace", + "description": "Resolve a workspace ID to the display metadata available to the authenticated credential. The credential must have read access to the workspace.", + "tags": ["Workspaces"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace to resolve." + } + ], + "responses": { + "200": { + "description": "The workspace's display metadata.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": [ + "id", + "name", + "color", + "logoUrl", + "mode", + "memberCount", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string" }, + "color": { "type": "string" }, + "logoUrl": { "type": ["string", "null"] }, + "mode": { + "type": "string", + "enum": ["personal", "organization", "grandfathered_shared"] + }, + "memberCount": { "type": "integer", "minimum": 0 }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Product Operations", + "color": "#7C3AED", + "logoUrl": null, + "mode": "organization", + "memberCount": 12, + "createdAt": "2026-08-07T18:00:00.000Z", + "updatedAt": "2026-08-07T18:30:00.000Z" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + } + } + } + }, + "/api/v2/chats": { + "get": { + "operationId": "listChats", + "summary": "List Sim Chats", + "description": "List a bounded page of the authenticated user's active workspace chats in the same pinned-first, recently-updated order used by the Sim Home UI. This personal history surface requires a personal API key; shared workspace keys cannot read their creator's private chats. Pass `nextCursor` back as `cursor` to load another page.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { "type": "string" }, + "description": "Workspace whose chats should be listed." + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { "type": "string", "maxLength": 200 }, + "description": "Case-insensitive title substring." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 30 }, + "description": "Maximum chats to return." + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { "type": "string" }, + "description": "Opaque cursor returned by the previous page." + } + ], + "responses": { + "200": { + "description": "A bounded page of chat summaries.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "updatedAt", "pinned", "active"], + "properties": { + "id": { "type": "string" }, + "title": { "type": ["string", "null"] }, + "updatedAt": { "type": "string", "format": "date-time" }, + "pinned": { "type": "boolean" }, + "active": { "type": "boolean" } + } + } + }, + "nextCursor": { "type": ["string", "null"] } + } + }, + "example": { + "data": [ + { + "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "title": "Review release workflow", + "updatedAt": "2026-08-07T18:30:00.000Z", + "pinned": true, + "active": false + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + }, + "/api/v2/chats/{chatId}": { + "get": { + "operationId": "getChat", + "summary": "Open Sim Chat", + "description": "Load one owned workspace chat as a display-safe user/assistant transcript and mint a fresh opaque continuation token for the requested safety mode. Internal tool payloads, stream IDs, resources, and replay metadata are not exposed. The subsequent chat POST still accepts only the continuation token, never this resource ID.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "chatId", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Chat resource ID returned by List Sim Chats." + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { "type": "string" }, + "description": "Workspace the chat must belong to." + }, + { + "name": "readOnly", + "in": "query", + "required": false, + "schema": { "type": "boolean", "default": false }, + "description": "Mint a continuation token for the secretless read-only chat mode." + } + ], + "responses": { + "200": { + "description": "The chat transcript and a fresh continuation token.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "title", "messages", "continuationToken", "active"], + "properties": { + "id": { "type": "string" }, + "title": { "type": ["string", "null"] }, + "messages": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "role", "content", "timestamp"], + "properties": { + "id": { "type": "string" }, + "role": { "type": "string", "enum": ["user", "assistant"] }, + "content": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" } + } + } + }, + "continuationToken": { "type": "string" }, + "active": { "type": "boolean" } + } + } + } + }, + "example": { + "data": { + "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "title": "Review release workflow", + "messages": [ + { + "id": "msg_1", + "role": "user", + "content": "Review the release workflow", + "timestamp": "2026-08-07T18:29:00.000Z" + }, + { + "id": "msg_2", + "role": "assistant", + "content": "The workflow is ready to release.", + "timestamp": "2026-08-07T18:30:00.000Z" + } + ], + "continuationToken": "sim-v2-chat-v1.opaque.refreshed", + "active": false + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + }, + "patch": { + "operationId": "renameChat", + "summary": "Rename Sim Chat", + "description": "Rename an owned Sim Chat and synchronize the new title with the Sim Home chat list. This private history operation requires a personal API key; shared workspace keys cannot rename a creator's chats.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "chatId", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Chat resource ID returned by List Sim Chats." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "title"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace the chat must belong to." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "New chat title. Leading and trailing whitespace is removed." + } + } + }, + "example": { + "workspaceId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "title": "Incident investigation" + } + } + } + }, + "responses": { + "200": { + "description": "The renamed chat.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "title"], + "properties": { + "id": { "type": "string" }, + "title": { "type": "string", "minLength": 1, "maxLength": 200 } + } + } + } + }, + "example": { + "data": { + "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "title": "Incident investigation" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + }, + "/api/v2/chat": { + "post": { + "operationId": "chat", + "summary": "Ask Sim Chat", + "description": "Chat with the Mothership agent for a workspace. Personal API keys use their owner's current workspace permission, integrations, credentials, environment context, and memory, and their conversations are synchronized with the Sim Home chat history. Shared workspace keys retain normal workspace capabilities but do not inherit a human owner's personal integrations, secrets, environment, memory, or private chat history. Set `readOnly` to select the subtractive, secretless workspace-query policy. Omit `continuationToken` for a one-shot or first interactive turn, then send the latest opaque token returned by the stream to continue the same conversation. Tokens are bound to the workspace, authorization principal, credential type, and read-only mode, and expire on a rolling 24-hour window; this chat POST never accepts a raw chat ID. Set `async: true` with a personal API key to keep an accepted persisted turn running after disconnect; its first accepted `session` event includes a durable `runId` that can be polled through the chat-run endpoints. `runId` is omitted for ordinary synchronous turns. Set `persistChat: false` to suppress persistence of a newly created chat; that mode cannot be asynchronous. The response is a Server-Sent Events stream. `text` events contain incremental assistant output and `complete` contains the authoritative final result. Comment frames are heartbeats and `data: [DONE]` closes a successful stream. The caller's Sim API key selects and authorizes the local workspace but is never forwarded to Mothership. Workspace keys use the workspace billing account as their system actor while local tool authorization remains bound to the key owner; personal keys use their owner. Inline attachments are base64-only: paths and URLs are not accepted or resolved.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "prompt"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace Sim Chat should operate in." + }, + "prompt": { + "type": "string", + "maxLength": 10485760, + "x-maxUtf8Bytes": 10485760, + "description": "The instruction or question for Sim Chat. UTF-8 input is limited to 10 MiB. It may be empty or whitespace only when at least one attachment is present; the server supplies a neutral inspect-the-attachments instruction in that case." + }, + "continuationToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Latest opaque continuation token returned by a prior `session` or `complete` event. Never send a raw chat or conversation ID." + }, + "readOnly": { + "type": "boolean", + "default": false, + "description": "Use the secretless, read-only workspace-query policy. The default keeps normal workspace capabilities; shared workspace credentials still exclude personal integrations, secrets, environment, and persistent memory." + }, + "async": { + "type": "boolean", + "default": false, + "description": "Keep an accepted persisted turn running after the caller disconnects and return its durable run ID in the session event. Requires a personal API key and `persistChat: true`; poll the chat-run endpoints for progress." + }, + "persistChat": { + "type": "boolean", + "default": true, + "description": "Allow a new conversation to be persisted in the workspace chat list. Setting this to false suppresses new-chat persistence and cannot be combined with `async: true`; an existing persisted chat remains persisted when continued." + }, + "attachments": { + "type": "array", + "maxItems": 5, + "description": "Optional inline attachments, accepted on initial and continuation turns. Decoded aggregate size is limited to 10 MiB. Images and PDFs are limited to 5 MiB each; UTF-8 text is limited to 200 KiB each. Each image may be at most 8192 pixels on either axis and 16,000,000 total pixels; all images in one request may total at most 32,000,000 decoded pixels.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "mediaType", "data"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "File basename only. Directory separators and control characters are rejected." + }, + "mediaType": { + "type": "string", + "enum": [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", + "text/plain", + "text/markdown", + "text/csv", + "text/tab-separated-values", + "text/html", + "text/css", + "text/javascript", + "text/typescript", + "text/xml", + "text/yaml", + "application/json", + "application/jsonl", + "application/x-ndjson", + "application/xml", + "application/yaml", + "application/x-yaml", + "application/toml" + ], + "description": "Declared MIME type. Image and PDF bytes are sniffed; text must decode as UTF-8." + }, + "data": { + "type": "string", + "minLength": 4, + "maxLength": 13981016, + "contentEncoding": "base64", + "description": "Canonical standard base64 bytes. Data URLs and base64url are not accepted." + } + } + } + }, + "contexts": { + "type": "array", + "maxItems": 50, + "description": "Optional identity-bearing workspace resources, skills, and MCP servers to inject for this turn. Resource kinds correspond to `@` tags; `skill` and `mcp` correspond to `/` tags. MCP contexts are ignored for read-only requests and shared workspace API keys.", + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "workflowId", "label"], + "properties": { + "kind": { "type": "string", "const": "workflow" }, + "workflowId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "tableId", "label"], + "properties": { + "kind": { "type": "string", "const": "table" }, + "tableId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "fileId", "label"], + "properties": { + "kind": { "type": "string", "const": "file" }, + "fileId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "knowledgeId", "label"], + "properties": { + "kind": { "type": "string", "const": "knowledge" }, + "knowledgeId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "executionId", "label"], + "properties": { + "kind": { "type": "string", "const": "logs" }, + "executionId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "skillId", "label"], + "properties": { + "kind": { "type": "string", "const": "skill" }, + "skillId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "serverId", "label"], + "properties": { + "kind": { "type": "string", "const": "mcp" }, + "serverId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + } + ] + } + } + } + }, + "example": { + "workspaceId": "ws_abc123", + "prompt": "Summarize the attached notes and compare them with this workspace.", + "async": true, + "attachments": [ + { + "name": "notes.md", + "mediaType": "text/markdown", + "data": "IyBOb3Rlcwo=" + } + ] } } - }, - "202": { - "description": "Resume execution has been queued for asynchronous processing. Poll the statusUrl for results.", + } + }, + "responses": { + "200": { + "description": "A Sim Chat SSE stream.", + "headers": { + "X-RateLimit-Limit": { + "description": "API request bucket capacity.", + "schema": { "type": "integer" } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current bucket.", + "schema": { "type": "integer" } + }, + "X-RateLimit-Reset": { + "description": "When the current API request bucket resets.", + "schema": { "type": "string", "format": "date-time" } + } + }, "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AsyncExecutionResult" - }, - "example": { - "success": true, - "async": true, - "jobId": "job_4a3b2c1d0e", - "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", - "message": "Resume execution queued", - "statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" - } + "text/event-stream": { + "schema": { "type": "string" }, + "example": "data: {\"type\":\"session\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"requestId\":\"req_123\",\"chatId\":\"80a47295-040e-46f9-9ea8-ad78eff3bcab\",\"runId\":\"4bfa6f89-b746-43be-8246-bf1c69b58593\"}\n\ndata: {\"type\":\"text\",\"delta\":\"Two workflows...\"}\n\ndata: {\"type\":\"complete\",\"data\":{\"content\":\"Two workflows...\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"usage\":{\"prompt\":120,\"completion\":18,\"total\":138}}}\n\ndata: [DONE]\n\n" } } }, "400": { - "$ref": "#/components/responses/BadRequest" + "$ref": "#/components/responses/V2BadRequest" }, "401": { - "$ref": "#/components/responses/Unauthorized" + "$ref": "#/components/responses/V2Unauthorized" + }, + "402": { + "$ref": "#/components/responses/V2UsageLimitExceeded" }, "403": { - "$ref": "#/components/responses/Forbidden" + "$ref": "#/components/responses/V2Forbidden" }, "404": { - "$ref": "#/components/responses/NotFound" + "$ref": "#/components/responses/V2NotFound" }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string", - "description": "Human-readable error message." - } - } - } - } - } + "409": { + "$ref": "#/components/responses/V2Conflict" + }, + "413": { + "$ref": "#/components/responses/V2PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/V2UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" }, "503": { - "description": "Failed to queue the resume execution. Retry the request.", + "$ref": "#/components/responses/V2ServiceUnavailable" + } + } + } + }, + "/api/v2/chat/runs": { + "get": { + "operationId": "listChatRuns", + "summary": "List Sim Chat Runs", + "description": "List a bounded page of the authenticated user's root Mothership runs for one workspace, newest first. This private history surface requires a personal API key. It returns durable run state and safe chat metadata only; stream IDs, continuation tokens, model reasoning, tool payloads, and errors are never exposed. Pass `nextCursor` back as `cursor` to load another page.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { "type": "string" }, + "description": "Workspace whose owned Sim Chat runs should be listed." + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "active", + "paused_waiting_for_tool", + "resuming", + "complete", + "error", + "cancelled" + ] + }, + "description": "Return only runs with this durable status." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 30 }, + "description": "Maximum runs to return." + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { "type": "string", "minLength": 1 }, + "description": "Opaque cursor returned by the previous page." + } + ], + "responses": { + "200": { + "description": "A bounded page of safe chat run summaries.", "content": { "application/json": { "schema": { "type": "object", + "required": ["data", "nextCursor"], "properties": { - "error": { - "type": "string", - "description": "Error message." - } + "data": { + "type": "array", + "items": { "$ref": "#/components/schemas/V2ChatRunSummary" } + }, + "nextCursor": { "type": ["string", "null"] } } + }, + "example": { + "data": [ + { + "runId": "4bfa6f89-b746-43be-8246-bf1c69b58593", + "chatId": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "chatTitle": "Review release workflow", + "status": "complete", + "startedAt": "2026-08-08T18:29:00.000Z", + "completedAt": "2026-08-08T18:30:00.000Z" + } + ], + "nextCursor": null } } } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" } } } }, - "/api/users/me/usage-limits": { + "/api/v2/chat/runs/{runId}": { "get": { - "operationId": "getUsageLimits", - "summary": "Get Usage Limits", - "description": "Retrieve your current usage spending and storage consumption for the billing period.", - "tags": ["Usage"], - "x-codeSamples": [ + "operationId": "getChatRun", + "summary": "Get Sim Chat Run", + "description": "Poll one owned root Mothership run. In addition to durable status and chat metadata, the response contains accumulated root-assistant text and chronological display-safe activity updates when complete replay is available. A terminal run falls back to its persisted assistant response after replay expires. Raw argument/result objects, model reasoning, upstream errors, stream IDs, and continuation tokens are never returned; activity labels may summarize the same user-visible target or operation shown in Sim Home. Runs outside the user, workspace, live Mothership chat, or root-run scope all return the same 404.", + "tags": ["Chat"], + "security": [ { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/users/me/usage-limits\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "apiKey": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Run ID returned by Ask Sim Chat or List Sim Chat Runs." + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { "type": "string" }, + "description": "Workspace the run and its chat must belong to." } ], "responses": { "200": { - "description": "Current usage and storage information.", + "description": "A safe snapshot of the chat run.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UsageLimits" + "type": "object", + "required": ["data"], + "properties": { + "data": { "$ref": "#/components/schemas/V2ChatRunDetail" } + } }, "example": { - "success": true, - "usage": { - "currentPeriodCost": 12.5, - "limit": 100, - "plan": "pro" - }, - "storage": { - "usedBytes": 5242880, - "limitBytes": 1073741824, - "percentUsed": 0.49 + "data": { + "runId": "4bfa6f89-b746-43be-8246-bf1c69b58593", + "chatId": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "chatTitle": "Review release workflow", + "status": "active", + "startedAt": "2026-08-08T18:29:00.000Z", + "completedAt": null, + "response": "I reviewed the release workflow.", + "activities": [ + { + "kind": "tool", + "id": "tool-1", + "label": "Read workflow", + "state": "complete" + } + ] } } } } }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, "401": { - "$ref": "#/components/responses/Unauthorized" + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + }, + "503": { + "$ref": "#/components/responses/V2ServiceUnavailable" } - }, - "parameters": [] + } } }, "/api/v2/billing/status": { @@ -2051,6 +2929,88 @@ } } }, + "V2ChatRunSummary": { + "type": "object", + "required": ["runId", "chatId", "chatTitle", "status", "startedAt", "completedAt"], + "properties": { + "runId": { "type": "string", "format": "uuid" }, + "chatId": { "type": "string", "format": "uuid" }, + "chatTitle": { "type": ["string", "null"] }, + "status": { + "type": "string", + "enum": [ + "active", + "paused_waiting_for_tool", + "resuming", + "complete", + "error", + "cancelled" + ] + }, + "startedAt": { "type": "string", "format": "date-time" }, + "completedAt": { "type": ["string", "null"], "format": "date-time" } + } + }, + "V2ChatRunActivity": { + "oneOf": [ + { + "type": "object", + "required": ["kind", "id", "label", "state"], + "properties": { + "kind": { "type": "string", "enum": ["subagent", "tool"] }, + "id": { "type": "string" }, + "parentId": { "type": "string" }, + "label": { "type": "string" }, + "state": { "type": "string", "enum": ["running", "complete", "error"] } + } + }, + { + "type": "object", + "required": ["kind", "parentId", "delta"], + "properties": { + "kind": { "type": "string", "const": "narration" }, + "parentId": { "type": "string" }, + "delta": { "type": "string" } + } + } + ] + }, + "V2ChatRunDetail": { + "type": "object", + "required": [ + "runId", + "chatId", + "chatTitle", + "status", + "startedAt", + "completedAt", + "response", + "activities" + ], + "properties": { + "runId": { "type": "string", "format": "uuid" }, + "chatId": { "type": "string", "format": "uuid" }, + "chatTitle": { "type": ["string", "null"] }, + "status": { + "type": "string", + "enum": [ + "active", + "paused_waiting_for_tool", + "resuming", + "complete", + "error", + "cancelled" + ] + }, + "startedAt": { "type": "string", "format": "date-time" }, + "completedAt": { "type": ["string", "null"], "format": "date-time" }, + "response": { "type": "string" }, + "activities": { + "type": "array", + "items": { "$ref": "#/components/schemas/V2ChatRunActivity" } + } + } + }, "V2Error": { "type": "object", "required": ["error"], @@ -2248,6 +3208,56 @@ } } }, + "V2NotFound": { + "description": "The requested resource does not exist or is not visible to the credential.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2Conflict": { + "description": "The chat already has a response in progress. Retry after that response finishes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2UsageLimitExceeded": { + "description": "The resolved workspace payer or organization member has reached a usage limit.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2PayloadTooLarge": { + "description": "The request body or decoded attachment limits were exceeded.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2UnsupportedMediaType": { + "description": "An attachment media type or its decoded bytes are unsupported.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, "V2RateLimited": { "description": "Rate limit exceeded; retry after the window resets.", "content": { @@ -2257,6 +3267,16 @@ } } } + }, + "V2ServiceUnavailable": { + "description": "Sim Chat is not configured or temporarily unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } } } } diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index c3cf92abbce..7620106b4f6 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -467,16 +467,16 @@ }, "/api/v2/files/{fileId}": { "get": { - "operationId": "downloadFile", - "summary": "Download File", - "description": "Download the raw bytes of a file. The success response body is the file content itself — there is no JSON envelope. The actual `Content-Type` reflects the stored file's MIME type (shown here as `application/octet-stream`); `Content-Disposition` and `Content-Length` describe the attachment, and rate-limit state is returned in the `X-RateLimit-*` headers. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`. Error responses still use the canonical v2 JSON error envelope.", + "operationId": "describeFile", + "summary": "Describe File", + "description": "Return one workspace file's metadata and current sharing state without downloading its content. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`.", "tags": ["Files"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -o downloaded-file.csv" + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], "parameters": [ @@ -489,26 +489,8 @@ ], "responses": { "200": { - "description": "The raw file content as binary data. The `Content-Type` header reflects the file's stored MIME type.", + "description": "The file metadata and sharing state.", "headers": { - "Content-Type": { - "description": "MIME type of the file. Varies per file; defaults to application/octet-stream when unknown.", - "schema": { - "type": "string" - } - }, - "Content-Disposition": { - "description": "Attachment disposition carrying the (sanitized and RFC 5987 encoded) filename.", - "schema": { - "type": "string" - } - }, - "Content-Length": { - "description": "Size of the file in bytes.", - "schema": { - "type": "string" - } - }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -520,10 +502,9 @@ } }, "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/V2FileDescriptionResponse" } } } @@ -708,71 +689,6 @@ } } }, - "/api/v2/files/{fileId}/metadata": { - "get": { - "operationId": "getFile", - "summary": "Get File Metadata", - "description": "Return one workspace file's metadata without downloading its content. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`.", - "tags": ["Files"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}/metadata?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/FileIdPath" - }, - { - "$ref": "#/components/parameters/WorkspaceIdQuery" - } - ], - "responses": { - "200": { - "description": "The file metadata.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2FileMetadataResponse" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, "/api/v2/audit-logs": { "get": { "operationId": "listAuditLogs", @@ -1152,95 +1068,17 @@ } }, "/api/v2/files/{fileId}/share": { - "get": { - "operationId": "getFileShare", - "summary": "Get File Share", - "description": "Read a file's public share state. `share` is `null` when the file has never been shared. The encrypted password is never returned — `hasPassword` is the only password signal.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, delete the file instead.", - "tags": ["Files"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X GET \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share?workspaceId=YOUR_WORKSPACE_ID\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/FileIdPath" - }, - { - "$ref": "#/components/parameters/WorkspaceIdQuery" - } - ], - "responses": { - "200": { - "description": "The file's share state.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2GetFileShareResponse" - }, - "example": { - "data": { - "share": { - "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", - "token": "share-token-example", - "url": "https://www.sim.ai/f/share-token-example", - "isActive": true, - "resourceType": "file", - "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", - "authType": "public", - "hasPassword": false, - "allowedEmails": [] - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, "put": { - "operationId": "upsertFileShare", - "summary": "Enable or Disable File Share", - "description": "Enable or disable a file's public share. Requires workspace `write`.\n\nThe share token is always server-generated; there is no way to supply one. `authType` selects how the link is gated: `public` (anyone with the link), `password` (requires `password` on first enable), or `email` / `sso` (requires a non-empty `allowedEmails`). Omitting `authType` on a re-enable keeps the stored mode, and the org access-control policy is evaluated against that stored mode rather than against `public`. Disabling is never blocked by the policy.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, delete the file instead.", + "operationId": "shareFile", + "summary": "Share File", + "description": "Enable a file's public share or update its access settings. Requires workspace `write`. The share token is always server-generated. Omitting `authType` on a re-enable keeps the stored mode.", "tags": ["Files"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"isActive\": true, \"authType\": \"public\"}'" + "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"authType\": \"public\"}'" } ], "parameters": [ @@ -1254,17 +1092,13 @@ "application/json": { "schema": { "type": "object", - "required": ["workspaceId", "isActive"], + "required": ["workspaceId"], "properties": { "workspaceId": { "type": "string", "description": "The workspace that owns the file.", "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" }, - "isActive": { - "type": "boolean", - "description": "Whether the share should resolve. `false` disables without revoking." - }, "authType": { "type": "string", "enum": ["public", "password", "email", "sso"], @@ -1293,7 +1127,6 @@ "summary": "Enable a public link", "value": { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isActive": true, "authType": "public" } }, @@ -1301,17 +1134,9 @@ "summary": "Enable a password-protected link", "value": { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isActive": true, "authType": "password", "password": "EXAMPLE_PASSWORD" } - }, - "disable": { - "summary": "Disable (keeps the token and stored config)", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isActive": false - } } } } @@ -1319,7 +1144,7 @@ }, "responses": { "200": { - "description": "The share after the update.", + "description": "The enabled sharing state.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1334,17 +1159,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2UpsertFileShareResponse" + "$ref": "#/components/schemas/V2ShareFileResponse" }, "example": { "data": { - "share": { - "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", - "token": "share-token-example", + "sharing": { + "enabled": true, "url": "https://www.sim.ai/f/share-token-example", - "isActive": true, - "resourceType": "file", - "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", "authType": "public", "hasPassword": false, "allowedEmails": [] @@ -1373,9 +1194,133 @@ "$ref": "#/components/responses/InternalError" } } + }, + "delete": { + "operationId": "unshareFile", + "summary": "Unshare File", + "description": "Disable a file's public share while preserving its token and stored access settings for a future re-enable. The operation is idempotent.", + "tags": ["Files"], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "Sharing is disabled.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2UnshareFileResponse" + }, + "example": { + "data": { + "sharing": { + "enabled": false + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } } }, "/api/v2/files/{fileId}/content": { + "get": { + "operationId": "getFileContent", + "summary": "Get File Content", + "description": "Stream the raw bytes of a file. The success response is the content itself, without a JSON envelope. Errors still use the canonical v2 JSON envelope.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}/content?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -o downloaded-file.csv" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The raw file content as binary data.", + "headers": { + "Content-Type": { + "description": "The file's stored MIME type.", + "schema": { + "type": "string" + } + }, + "Content-Disposition": { + "description": "Attachment disposition carrying the filename.", + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the file in bytes.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, "put": { "operationId": "updateFileContent", "summary": "Replace File Content", @@ -2196,37 +2141,13 @@ } } }, - "V2FileMetadata": { - "allOf": [ - { - "$ref": "#/components/schemas/V2File" - }, - { - "type": "object", - "required": ["share"], - "properties": { - "share": { - "oneOf": [ - { - "$ref": "#/components/schemas/V2FileShare" - }, - { - "type": "null" - } - ], - "description": "The file's public share state, or null when the file has never been shared." - } - } - } - ] - }, - "V2FileMetadataResponse": { + "V2FileDescriptionResponse": { "type": "object", - "description": "A single file resource with its public share state.", + "description": "A single file with its current sharing state.", "required": ["data"], "properties": { "data": { - "$ref": "#/components/schemas/V2FileMetadata" + "$ref": "#/components/schemas/V2FileDescription" } } }, @@ -2292,69 +2213,73 @@ } } }, - "V2FileShare": { + "V2DisabledFileSharing": { "type": "object", - "description": "A file's public share. Never carries the storage key or the encrypted password — `hasPassword` is the only password signal exposed.", - "required": [ - "id", - "token", - "url", - "isActive", - "resourceType", - "resourceId", - "authType", - "hasPassword", - "allowedEmails" - ], + "required": ["enabled"], "properties": { - "id": { - "type": "string", - "description": "Unique share identifier.", - "example": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb" - }, - "token": { - "type": "string", - "description": "The public token embedded in the share URL. Always server-generated.", - "example": "share-token-example" - }, - "url": { - "type": "string", - "format": "uri", - "description": "The public share URL.", - "example": "https://www.sim.ai/f/share-token-example" - }, - "isActive": { + "enabled": { "type": "boolean", - "description": "Whether the share currently resolves. Disabling does not revoke — see the endpoint description." - }, - "resourceType": { - "type": "string", - "enum": ["file", "folder"], - "description": "The kind of resource shared. Always `file` on this surface." + "const": false + } + } + }, + "V2EnabledFileSharing": { + "type": "object", + "required": ["enabled", "url", "authType", "hasPassword", "allowedEmails"], + "properties": { + "enabled": { + "type": "boolean", + "const": true }, - "resourceId": { + "url": { "type": "string", - "description": "The shared resource id.", - "example": "wf_V1StGXR8z5jdHi6BmyT91" + "format": "uri" }, "authType": { "type": "string", - "enum": ["public", "password", "email", "sso"], - "description": "How the share is gated." + "enum": ["public", "password", "email", "sso"] }, "hasPassword": { - "type": "boolean", - "description": "Whether a password is stored for this share." + "type": "boolean" }, "allowedEmails": { "type": "array", + "maxItems": 200, "items": { - "type": "string" - }, - "description": "Allow-list of addresses or `@domain` patterns for `email`/`sso` shares. Empty otherwise." + "type": "string", + "minLength": 1, + "maxLength": 320 + } } } }, + "V2FileSharing": { + "description": "The file's current sharing state. Disabled sharing carries no stale access details.", + "oneOf": [ + { + "$ref": "#/components/schemas/V2DisabledFileSharing" + }, + { + "$ref": "#/components/schemas/V2EnabledFileSharing" + } + ] + }, + "V2FileDescription": { + "allOf": [ + { + "$ref": "#/components/schemas/V2File" + }, + { + "type": "object", + "required": ["sharing"], + "properties": { + "sharing": { + "$ref": "#/components/schemas/V2FileSharing" + } + } + } + ] + }, "V2MoveFileItemsResult": { "type": "object", "description": "What the move actually relocated.", @@ -2371,31 +2296,23 @@ } } }, - "V2GetFileShareResult": { + "V2ShareFileResult": { "type": "object", - "description": "The file's share state, or null when the file has never been shared.", - "required": ["share"], + "description": "The enabled file sharing state.", + "required": ["sharing"], "properties": { - "share": { - "oneOf": [ - { - "$ref": "#/components/schemas/V2FileShare" - }, - { - "type": "null" - } - ], - "description": "The share, or null when the file has never been shared." + "sharing": { + "$ref": "#/components/schemas/V2EnabledFileSharing" } } }, - "V2UpsertFileShareResult": { + "V2UnshareFileResult": { "type": "object", - "description": "The share after the upsert.", - "required": ["share"], + "description": "The disabled file sharing state.", + "required": ["sharing"], "properties": { - "share": { - "$ref": "#/components/schemas/V2FileShare" + "sharing": { + "$ref": "#/components/schemas/V2DisabledFileSharing" } } }, @@ -2409,23 +2326,23 @@ } } }, - "V2GetFileShareResponse": { + "V2ShareFileResponse": { "type": "object", - "description": "The file's public share state.", + "description": "The enabled sharing state.", "required": ["data"], "properties": { "data": { - "$ref": "#/components/schemas/V2GetFileShareResult" + "$ref": "#/components/schemas/V2ShareFileResult" } } }, - "V2UpsertFileShareResponse": { + "V2UnshareFileResponse": { "type": "object", - "description": "The share after enabling or disabling it.", + "description": "The disabled sharing state.", "required": ["data"], "properties": { "data": { - "$ref": "#/components/schemas/V2UpsertFileShareResult" + "$ref": "#/components/schemas/V2UnshareFileResult" } } }, diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index e837a5aba9b..6ba47422718 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -4766,11 +4766,6 @@ "data": { "$ref": "#/components/schemas/RowData" }, - "__privateSecretProvenance": { - "type": "object", - "writeOnly": true, - "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." - }, "afterRowId": { "type": "string", "minLength": 1, @@ -4793,11 +4788,6 @@ "minLength": 1, "description": "The workspace that owns the table." }, - "__privateSecretProvenance": { - "type": "object", - "writeOnly": true, - "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." - }, "rows": { "type": "array", "minItems": 1, @@ -4836,11 +4826,6 @@ "data": { "$ref": "#/components/schemas/RowData" }, - "__privateSecretProvenance": { - "type": "object", - "writeOnly": true, - "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." - }, "limit": { "type": "integer", "minimum": 1, @@ -4921,11 +4906,6 @@ }, "data": { "$ref": "#/components/schemas/RowData" - }, - "__privateSecretProvenance": { - "type": "object", - "writeOnly": true, - "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." } } }, @@ -4942,11 +4922,6 @@ "data": { "$ref": "#/components/schemas/RowData" }, - "__privateSecretProvenance": { - "type": "object", - "writeOnly": true, - "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." - }, "conflictTarget": { "type": "string", "minLength": 1, diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index ee12eb685b1..9c313c8f9e8 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1157,7 +1157,7 @@ "type": "integer", "minimum": 1, "maximum": 604800, - "description": "Server-side timeout for an async run, in seconds. Valid only when async is true." + "description": "Optional server-side timeout for an async run, in seconds. Requires async=true and cannot extend the account policy." }, "stream": { "type": "boolean", diff --git a/apps/sim/AGENTS.md b/apps/sim/AGENTS.md index 6c52c2df02d..6366615da3c 100644 --- a/apps/sim/AGENTS.md +++ b/apps/sim/AGENTS.md @@ -229,3 +229,13 @@ export function useEntityList(workspaceId?: string) { - **Check existing sources** before duplicating (`lib/` has many utilities) - **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use) + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/sim/app/api/cli/auth/approve/route.test.ts b/apps/sim/app/api/cli/auth/approve/route.test.ts index b270c7567cf..ff7902092a5 100644 --- a/apps/sim/app/api/cli/auth/approve/route.test.ts +++ b/apps/sim/app/api/cli/auth/approve/route.test.ts @@ -5,11 +5,13 @@ import { createHash } from 'node:crypto' import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockCreateApproval: vi.fn(), - mockEnforceUserRateLimit: vi.fn(), -})) +const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit, mockGetPermissions } = + vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockCreateApproval: vi.fn(), + mockEnforceUserRateLimit: vi.fn(), + mockGetPermissions: vi.fn(), + })) vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, @@ -24,6 +26,10 @@ vi.mock('@/lib/core/rate-limiter', () => ({ enforceUserRateLimit: mockEnforceUserRateLimit, })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetPermissions, +})) + import { POST } from '@/app/api/cli/auth/approve/route' const REQUEST = 'a'.repeat(43) @@ -35,6 +41,7 @@ describe('POST /api/cli/auth/approve', () => { mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockEnforceUserRateLimit.mockResolvedValue(null) mockCreateApproval.mockResolvedValue(undefined) + mockGetPermissions.mockResolvedValue('admin') }) it('records the approval for the signed-in user', async () => { @@ -43,7 +50,112 @@ describe('POST /api/cli/auth/approve', () => { ) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ ok: true }) - expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'copilot', + workspaceId: undefined, + workspaceBound: false, + }) + }) + + it('defaults to the copilot scope so pre-scope terminals keep working', async () => { + await POST(createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE })) + expect(mockCreateApproval).toHaveBeenCalledWith( + 'user-1', + REQUEST, + CHALLENGE, + expect.objectContaining({ scope: 'copilot' }) + ) + }) + + it('records a workspace binding when the approver is a workspace admin', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it("records a non-admin's pick as a default without binding the key to it", async () => { + mockGetPermissions.mockResolvedValue('write') + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: false, + }) + }) + + it('refuses to bind a key to a workspace the approver is not admin of', async () => { + mockGetPermissions.mockResolvedValue('write') + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(403) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses a workspace the approver is not a member of', async () => { + mockGetPermissions.mockResolvedValue(null) + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(404) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses bindKeyToWorkspace with no workspaceId', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(400) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses a workspace binding on the copilot scope', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'copilot', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(400) + expect(mockCreateApproval).not.toHaveBeenCalled() }) it('rejects an unauthenticated caller', async () => { @@ -59,7 +171,7 @@ describe('POST /api/cli/auth/approve', () => { await POST( createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE, userId: 'attacker' }) ) - expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, expect.anything()) }) it('rejects a malformed challenge', async () => { diff --git a/apps/sim/app/api/cli/auth/approve/route.ts b/apps/sim/app/api/cli/auth/approve/route.ts index 8099914be91..3c361a9bf45 100644 --- a/apps/sim/app/api/cli/auth/approve/route.ts +++ b/apps/sim/app/api/cli/auth/approve/route.ts @@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth' import { createApproval } from '@/lib/cli-auth/approval-store' import { enforceUserRateLimit } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CliAuthApproveAPI') @@ -16,6 +17,10 @@ const logger = createLogger('CliAuthApproveAPI') * The approving user comes from the session and nothing else — a client-supplied * user id here would let any caller approve a request redeemable for someone * else's key. No key is generated until the CLI polls. + * + * Workspace binding is authorized here rather than at poll time: the poll is + * unauthenticated by necessity, so it has no session to check a permission + * against. Approving is the only moment a human is present. */ export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() @@ -29,8 +34,56 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(approveCliAuthContract, request, {}) if (!parsed.success) return parsed.response - await createApproval(session.user.id, parsed.data.body.request, parsed.data.body.challenge) - logger.info('Recorded CLI authorization approval', { userId: session.user.id }) + const { request: requestId, challenge, scope, workspaceId, bindKeyToWorkspace } = parsed.data.body + + if ((workspaceId || bindKeyToWorkspace) && scope !== 'platform') { + return NextResponse.json( + { error: 'workspaceId is only valid for the platform scope' }, + { status: 400 } + ) + } + + if (bindKeyToWorkspace && !workspaceId) { + return NextResponse.json( + { error: 'bindKeyToWorkspace requires a workspaceId' }, + { status: 400 } + ) + } + + if (workspaceId) { + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + + // Reading the workspace at all requires membership. Without this, the + // terminal could be handed the id of a workspace the approver cannot see — + // harmless for the key, but it would silently become the profile default and + // every later command would 403 with no explanation. + if (!permission) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + // Minting a workspace key is an admin action wherever else it is offered; + // the terminal is not a lower bar. Rejected outright rather than downgraded + // to a personal key, so the CLI never quietly stores a different credential + // than the browser said it would. + if (bindKeyToWorkspace && permission !== 'admin') { + return NextResponse.json( + { error: 'Workspace admin permission is required to issue a workspace API key' }, + { status: 403 } + ) + } + } + + await createApproval(session.user.id, requestId, challenge, { + scope, + workspaceId, + workspaceBound: bindKeyToWorkspace, + }) + logger.info('Recorded CLI authorization approval', { + userId: session.user.id, + scope, + workspaceId: workspaceId ?? null, + workspaceBound: bindKeyToWorkspace, + }) return NextResponse.json({ ok: true }) }) diff --git a/apps/sim/app/api/cli/auth/poll/route.test.ts b/apps/sim/app/api/cli/auth/poll/route.test.ts index 89e7422a450..81709bd411b 100644 --- a/apps/sim/app/api/cli/auth/poll/route.test.ts +++ b/apps/sim/app/api/cli/auth/poll/route.test.ts @@ -9,12 +9,16 @@ const { mockCompleteApproval, mockReleaseMint, mockGenerateCopilotApiKey, + mockCreatePersonalApiKey, + mockCreateWorkspaceApiKey, mockEnforceIpRateLimit, } = vi.hoisted(() => ({ mockPollApproval: vi.fn(), mockCompleteApproval: vi.fn(), mockReleaseMint: vi.fn(), mockGenerateCopilotApiKey: vi.fn(), + mockCreatePersonalApiKey: vi.fn(), + mockCreateWorkspaceApiKey: vi.fn(), mockEnforceIpRateLimit: vi.fn(), })) @@ -29,6 +33,11 @@ vi.mock('@/lib/copilot/server/api-keys', () => ({ CopilotApiKeyError: class extends Error {}, })) +vi.mock('@/lib/api-key/orchestration', () => ({ + performCreatePersonalApiKey: mockCreatePersonalApiKey, + performCreateWorkspaceApiKey: mockCreateWorkspaceApiKey, +})) + vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mockEnforceIpRateLimit, })) @@ -42,11 +51,31 @@ function pollRequest(body: Record) { return createMockRequest('POST', body) } +/** What `pollApproval` returns for an approval recorded at the given scope. */ +function approved(overrides: Record = {}) { + return { + status: 'approved', + userId: 'user-1', + scope: 'copilot', + workspaceId: null, + workspaceBound: false, + ...overrides, + } +} + describe('POST /api/cli/auth/poll', () => { beforeEach(() => { vi.clearAllMocks() mockEnforceIpRateLimit.mockResolvedValue(null) mockGenerateCopilotApiKey.mockResolvedValue({ id: 'key-1', apiKey: 'sk-test' }) + mockCreatePersonalApiKey.mockResolvedValue({ + success: true, + key: { id: 'key-2', name: 'CLI', key: 'sim_personal', createdAt: new Date() }, + }) + mockCreateWorkspaceApiKey.mockResolvedValue({ + success: true, + key: { id: 'key-3', name: 'CLI', key: 'sim_workspace', createdAt: new Date() }, + }) mockCompleteApproval.mockResolvedValue(undefined) mockReleaseMint.mockResolvedValue(undefined) }) @@ -60,20 +89,89 @@ describe('POST /api/cli/auth/poll', () => { }) it('mints, then consumes the approval, once approved', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ status: 'complete', key: { id: 'key-1', apiKey: 'sk-test' }, + scope: 'copilot', + workspaceId: null, + workspaceBound: false, }) - expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith('user-1', expect.stringMatching(/^CLI /)) + // Second precision, not day: a date-only name made the second login of the + // day fail after the user had already approved in the browser. + expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith( + 'user-1', + expect.stringMatching(/^CLI \(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}Z\)$/) + ) expect(mockCompleteApproval).toHaveBeenCalledWith(REQUEST) expect(mockReleaseMint).not.toHaveBeenCalled() }) + it('mints a personal platform key when the approval carries no workspace', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'platform' })) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-2', apiKey: 'sim_personal' }, + scope: 'platform', + workspaceId: null, + workspaceBound: false, + }) + expect(mockCreatePersonalApiKey).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', source: 'cli' }) + ) + expect(mockGenerateCopilotApiKey).not.toHaveBeenCalled() + }) + + it('mints a workspace-scoped key when the approval carries a workspace', async () => { + mockPollApproval.mockResolvedValue( + approved({ scope: 'platform', workspaceId: 'ws-1', workspaceBound: true }) + ) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-3', apiKey: 'sim_workspace' }, + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + expect(mockCreateWorkspaceApiKey).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', workspaceId: 'ws-1', source: 'cli' }) + ) + expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() + }) + + it('returns the picked workspace with a personal key when the approval is unbound', async () => { + // A non-admin still picked a workspace in the browser; the terminal needs it + // as its default even though the key is not scoped to it. + mockPollApproval.mockResolvedValue(approved({ scope: 'platform', workspaceId: 'ws-1' })) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-2', apiKey: 'sim_personal' }, + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: false, + }) + expect(mockCreatePersonalApiKey).toHaveBeenCalled() + expect(mockCreateWorkspaceApiKey).not.toHaveBeenCalled() + }) + + it('scope comes from the approval, never from the poll body', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'copilot' })) + const response = await POST( + pollRequest({ request: REQUEST, verifier: VERIFIER, scope: 'platform' }) + ) + await expect(response.json()).resolves.toMatchObject({ scope: 'copilot' }) + expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() + }) + it('releases the reservation (keeps the approval) when minting fails', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) mockGenerateCopilotApiKey.mockRejectedValue(new Error('mothership down')) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(500) @@ -81,14 +179,30 @@ describe('POST /api/cli/auth/poll', () => { expect(mockCompleteApproval).not.toHaveBeenCalled() }) + it('releases the reservation when a platform mint fails', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'platform' })) + mockCreatePersonalApiKey.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'A personal API key named "CLI" already exists.', + }) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(409) + expect(mockReleaseMint).toHaveBeenCalledWith(REQUEST) + expect(mockCompleteApproval).not.toHaveBeenCalled() + }) + it('still returns the key when post-mint cleanup fails — never releases the lock', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) mockCompleteApproval.mockRejectedValue(new Error('redis blip')) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ status: 'complete', key: { id: 'key-1', apiKey: 'sk-test' }, + scope: 'copilot', + workspaceId: null, + workspaceBound: false, }) // A cleanup failure must not release the mint lock — that would allow a re-mint. expect(mockReleaseMint).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/cli/auth/poll/route.ts b/apps/sim/app/api/cli/auth/poll/route.ts index c5a7610f9de..c3e6e8c00c3 100644 --- a/apps/sim/app/api/cli/auth/poll/route.ts +++ b/apps/sim/app/api/cli/auth/poll/route.ts @@ -2,6 +2,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { pollCliAuthContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' +import { + performCreatePersonalApiKey, + performCreateWorkspaceApiKey, +} from '@/lib/api-key/orchestration' +import type { ApprovalGrant } from '@/lib/cli-auth/approval-store' import { completeApproval, pollApproval, releaseMint } from '@/lib/cli-auth/approval-store' import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/api-keys' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' @@ -23,9 +28,64 @@ const POLL_RATE_LIMIT: TokenBucketConfig = { refillIntervalMs: 60_000, } -/** Keys are named for the day they were issued, matching what the CLI prints. */ +/** + * Names a minted key for the instant it was issued, e.g. `CLI (2026-07-30 + * 15:42:07Z)`. + * + * Second precision, not day: key names are unique per owner, so a date-only + * name made the second login of the day fail outright with "a key named … + * already exists" — after the user had already approved in the browser. UTC so + * the name is unambiguous in a shared workspace list and sorts chronologically. + */ function cliKeyName(): string { - return `CLI (${new Date().toISOString().slice(0, 10)})` + return `CLI (${new Date().toISOString().slice(0, 19).replace('T', ' ')}Z)` +} + +/** + * Mints from the key space the approval recorded. + * + * A name collision is still surfaced rather than retried under a suffixed name: + * with second precision it means something genuinely unexpected, and silently + * accumulating near-identical rows would hide it. + */ +async function mintForGrant( + grant: ApprovalGrant +): Promise< + { ok: true; key: { id: string; apiKey: string } } | { ok: false; status: number; message: string } +> { + const name = cliKeyName() + + if (grant.scope === 'copilot') { + try { + const key = await generateCopilotApiKey(grant.userId, name) + return { ok: true, key } + } catch (error) { + const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined + return { ok: false, status: status ?? 500, message: 'Failed to generate copilot API key' } + } + } + + // `workspaceId` alone only names the terminal's default workspace; binding the + // key to it is a separate, admin-gated decision made at approval. + const result = + grant.workspaceBound && grant.workspaceId + ? await performCreateWorkspaceApiKey({ + workspaceId: grant.workspaceId, + userId: grant.userId, + name, + source: 'cli', + }) + : await performCreatePersonalApiKey({ userId: grant.userId, name, source: 'cli' }) + + if (!result.success || !result.key) { + return { + ok: false, + status: result.errorCode === 'conflict' ? 409 : 500, + message: result.error ?? 'Failed to generate API key', + } + } + + return { ok: true, key: { id: result.key.id, apiKey: result.key.key } } } /** @@ -49,17 +109,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ status: 'pending' }) } - let key: Awaited> - try { - key = await generateCopilotApiKey(result.userId, cliKeyName()) - } catch (error) { + const minted = await mintForGrant(result) + if (!minted.ok) { // Mint failed — release the reservation so a later poll can retry. await releaseMint(requestId) - const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined - return NextResponse.json( - { error: 'Failed to generate copilot API key' }, - { status: status ?? 500 } - ) + return NextResponse.json({ error: minted.message }, { status: minted.status }) } // Mint succeeded — the key exists. Consuming the approval is best-effort: a @@ -71,6 +125,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: result.userId, }) }) - logger.info('Minted CLI key on approved poll', { userId: result.userId }) - return NextResponse.json({ status: 'complete', key }) + logger.info('Minted CLI key on approved poll', { + userId: result.userId, + scope: result.scope, + workspaceId: result.workspaceId, + workspaceBound: result.workspaceBound, + }) + return NextResponse.json({ + status: 'complete', + key: minted.key, + scope: result.scope, + workspaceId: result.workspaceId, + workspaceBound: result.workspaceBound, + }) }) diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index d7d0ea2999d..df3dc0b9c40 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -234,6 +234,16 @@ describe('Knowledge Utils', () => { expect(result.hasAccess).toBe(false) expect('notFound' in result && result.notFound).toBe(true) }) + + it('treats a knowledge base outside the trusted workspace as not found', async () => { + queueTableRows(schemaMock.knowledgeBase, [ + { id: 'kb1', userId: 'user1', workspaceId: 'workspace-2' }, + ]) + + const result = await checkKnowledgeBaseAccess('kb1', 'user1', 'workspace-1') + + expect(result).toEqual({ hasAccess: false, notFound: true }) + }) }) describe('checkDocumentAccess', () => { diff --git a/apps/sim/app/api/knowledge/utils.ts b/apps/sim/app/api/knowledge/utils.ts index e92dc49f419..11fac039123 100644 --- a/apps/sim/app/api/knowledge/utils.ts +++ b/apps/sim/app/api/knowledge/utils.ts @@ -163,7 +163,8 @@ export type ChunkAccessCheck = ChunkAccessResult | ChunkAccessDenied async function resolveKnowledgeBaseAccess( knowledgeBaseId: string, userId: string, - requireWrite: boolean + requireWrite: boolean, + workspaceId?: string ): Promise { const kb = await db .select({ @@ -183,6 +184,10 @@ async function resolveKnowledgeBaseAccess( const kbData = kb[0] + if (workspaceId && kbData.workspaceId !== workspaceId) { + return { hasAccess: false, notFound: true } + } + if (kbData.workspaceId) { // Workspace KB: use workspace permissions only const userPermission = await getUserEntityPermissions(userId, 'workspace', kbData.workspaceId) @@ -205,9 +210,10 @@ async function resolveKnowledgeBaseAccess( */ export async function checkKnowledgeBaseAccess( knowledgeBaseId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, false) + return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, false, workspaceId) } /** @@ -219,9 +225,10 @@ export async function checkKnowledgeBaseAccess( */ export async function checkKnowledgeBaseWriteAccess( knowledgeBaseId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, true) + return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, true, workspaceId) } /** @@ -232,9 +239,15 @@ async function resolveDocumentAccess( knowledgeBaseId: string, documentId: string, userId: string, - requireWrite: boolean + requireWrite: boolean, + workspaceId?: string ): Promise { - const kbAccess = await resolveKnowledgeBaseAccess(knowledgeBaseId, userId, requireWrite) + const kbAccess = await resolveKnowledgeBaseAccess( + knowledgeBaseId, + userId, + requireWrite, + workspaceId + ) if (!kbAccess.hasAccess) { return { @@ -262,9 +275,10 @@ async function resolveDocumentAccess( export async function checkDocumentAccess( knowledgeBaseId: string, documentId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveDocumentAccess(knowledgeBaseId, documentId, userId, false) + return resolveDocumentAccess(knowledgeBaseId, documentId, userId, false, workspaceId) } /** @@ -274,9 +288,10 @@ export async function checkDocumentAccess( export async function checkDocumentWriteAccess( knowledgeBaseId: string, documentId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveDocumentAccess(knowledgeBaseId, documentId, userId, true) + return resolveDocumentAccess(knowledgeBaseId, documentId, userId, true, workspaceId) } /** diff --git a/apps/sim/app/api/users/me/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts index cd5f2eb83ca..b6776b51db6 100644 --- a/apps/sim/app/api/users/me/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -1,14 +1,12 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateShortId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createPersonalApiKeyContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' -import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' -import { hashApiKey } from '@/lib/api-key/crypto' +import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' +import { performCreatePersonalApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -73,70 +71,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { name } = parsed.data.body - const existingKey = await db - .select() - .from(apiKey) - .where(and(eq(apiKey.userId, userId), eq(apiKey.name, name), eq(apiKey.type, 'personal'))) - .limit(1) - - if (existingKey.length > 0) { - return NextResponse.json( - { - error: `A personal API key named "${name}" already exists. Please choose a different name.`, - }, - { status: 409 } - ) - } - - const { key: plainKey, encryptedKey } = await createApiKey(true) - - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') - } - - const [newKey] = await db - .insert(apiKey) - .values({ - id: generateShortId(), - userId, - workspaceId: null, - name, - key: encryptedKey, - keyHash: hashApiKey(plainKey), - type: 'personal', - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning({ - id: apiKey.id, - name: apiKey.name, - createdAt: apiKey.createdAt, - }) - - recordAudit({ - workspaceId: null, - actorId: userId, - action: AuditAction.PERSONAL_API_KEY_CREATED, - resourceType: AuditResourceType.API_KEY, - resourceId: newKey.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: name, - description: `Created personal API key: ${name}`, + const result = await performCreatePersonalApiKey({ + userId, + name, + actorName: session.user.name, + actorEmail: session.user.email, request, }) + if (!result.success || !result.key) { + const status = result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) + } captureServerEvent(userId, 'api_key_created', { key_name: name, scope: 'personal', }) - return NextResponse.json({ - key: { - ...newKey, - key: plainKey, - }, - }) + return NextResponse.json({ key: result.key }) } catch (error) { logger.error('Failed to create API key', { error }) return NextResponse.json({ error: 'Failed to create API key' }, { status: 500 }) diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts new file mode 100644 index 00000000000..b53ded5b476 --- /dev/null +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -0,0 +1,1865 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAcquirePendingChatStream, + mockCheckAttributedUsageLimits, + mockCheckRateLimit, + mockClearFilePreviewSessions, + mockCleanupAbortMarker, + mockCreateRunSegment, + mockEnv, + mockEnvFlags, + mockFinalizeStream, + mockFireTitleGeneration, + mockGenerateId, + mockGetAccessibleCopilotChatContinuationMetadata, + mockIssueV2ChatContinuationToken, + mockPersistCopilotUserMessage, + mockPrepareV2ChatAttachments, + mockPublishStatusChanged, + mockPublisherClose, + mockPublisherFlush, + mockPublisherPublish, + mockRegisterActiveStream, + mockReleasePendingChatStream, + mockResetBuffer, + mockResolveOrCreateChat, + mockRequestExplicitStreamAbort, + mockResolveBillingAttribution, + mockResolveSystemBillingAttribution, + mockResolveWorkspaceAccess, + mockRunWorkspaceChat, + mockScheduleBufferCleanup, + mockScheduleFilePreviewSessionCleanup, + mockStartAbortPoller, + mockStreamWriter, + mockTurnOnComplete, + mockTurnOnError, + mockUnregisterActiveStream, + mockVerifyV2ChatContinuationToken, + mockV2ApiGateError, +} = vi.hoisted(() => ({ + mockAcquirePendingChatStream: vi.fn(), + mockCheckAttributedUsageLimits: vi.fn(), + mockCheckRateLimit: vi.fn(), + mockClearFilePreviewSessions: vi.fn(), + mockCleanupAbortMarker: vi.fn(), + mockCreateRunSegment: vi.fn(), + mockEnv: { COPILOT_API_KEY: 'deployment-mothership-key' as string | undefined }, + mockEnvFlags: { isAuthDisabled: false }, + mockFinalizeStream: vi.fn(), + mockFireTitleGeneration: vi.fn(), + mockGenerateId: vi.fn(), + mockGetAccessibleCopilotChatContinuationMetadata: vi.fn(), + mockIssueV2ChatContinuationToken: vi.fn(), + mockPersistCopilotUserMessage: vi.fn(), + mockPrepareV2ChatAttachments: vi.fn(), + mockPublishStatusChanged: vi.fn(), + mockPublisherClose: vi.fn(), + mockPublisherFlush: vi.fn(), + mockPublisherPublish: vi.fn(), + mockRegisterActiveStream: vi.fn(), + mockReleasePendingChatStream: vi.fn(), + mockResetBuffer: vi.fn(), + mockResolveOrCreateChat: vi.fn(), + mockRequestExplicitStreamAbort: vi.fn(), + mockResolveBillingAttribution: vi.fn(), + mockResolveSystemBillingAttribution: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockRunWorkspaceChat: vi.fn(), + mockScheduleBufferCleanup: vi.fn(), + mockScheduleFilePreviewSessionCleanup: vi.fn(), + mockStartAbortPoller: vi.fn(), + mockStreamWriter: vi.fn(), + mockTurnOnComplete: vi.fn(), + mockTurnOnError: vi.fn(), + mockUnregisterActiveStream: vi.fn(), + mockVerifyV2ChatContinuationToken: vi.fn(), + mockV2ApiGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockV2ApiGateError, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + checkAttributedUsageLimits: mockCheckAttributedUsageLimits, + resolveBillingAttribution: mockResolveBillingAttribution, + resolveSystemBillingAttribution: mockResolveSystemBillingAttribution, +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + createRunSegment: mockCreateRunSegment, +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatContinuationMetadata: mockGetAccessibleCopilotChatContinuationMetadata, + resolveOrCreateChat: mockResolveOrCreateChat, +})) + +vi.mock('@/lib/copilot/chat/turn-persistence', () => ({ + buildCopilotTurnOnComplete: () => mockTurnOnComplete, + buildCopilotTurnOnError: () => mockTurnOnError, + persistCopilotUserMessage: mockPersistCopilotUserMessage, +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: mockPublishStatusChanged }, +})) + +vi.mock('@/lib/copilot/headless/workspace-chat', () => ({ + runWorkspaceChat: mockRunWorkspaceChat, + publicChatUsageLimitMessage: (content: string) => { + const match = /^(.+)<\/usage_upgrade>$/.exec(content) + if (!match) return null + return (JSON.parse(match[1]) as { message: string }).message + }, + toPublicChatResult: ( + result: { content: string; usage?: { prompt: number; completion: number } }, + continuationToken: string + ) => ({ + content: result.content, + continuationToken, + usage: result.usage + ? { + prompt: result.usage.prompt, + completion: result.usage.completion, + total: result.usage.prompt + result.usage.completion, + } + : {}, + }), +})) + +vi.mock('@/lib/copilot/headless/attachments', () => ({ + prepareV2ChatAttachments: mockPrepareV2ChatAttachments, +})) + +vi.mock('@/lib/copilot/headless/continuation-token', () => ({ + issueV2ChatContinuationToken: mockIssueV2ChatContinuationToken, + verifyV2ChatContinuationToken: mockVerifyV2ChatContinuationToken, +})) + +vi.mock('@/lib/copilot/request/session/explicit-abort', () => ({ + requestExplicitStreamAbort: mockRequestExplicitStreamAbort, +})) + +vi.mock('@/lib/copilot/request/lifecycle/finalize', () => ({ + finalizeStream: mockFinalizeStream, +})) + +vi.mock('@/lib/copilot/request/lifecycle/start', () => ({ + fireTitleGeneration: mockFireTitleGeneration, +})) + +vi.mock('@/lib/copilot/request/session', () => ({ + AbortReason: { UserStop: 'user_stop:abortActiveStream' }, + StreamWriter: mockStreamWriter, + acquirePendingChatStream: mockAcquirePendingChatStream, + clearFilePreviewSessions: mockClearFilePreviewSessions, + cleanupAbortMarker: mockCleanupAbortMarker, + encodeSSEComment: (comment: string) => new TextEncoder().encode(`: ${comment}\n\n`), + encodeSSEEnvelope: (value: unknown) => + new TextEncoder().encode(`data: ${JSON.stringify(value)}\n\n`), + registerActiveStream: mockRegisterActiveStream, + releasePendingChatStream: mockReleasePendingChatStream, + resetBuffer: mockResetBuffer, + scheduleBufferCleanup: mockScheduleBufferCleanup, + scheduleFilePreviewSessionCleanup: mockScheduleFilePreviewSessionCleanup, + SSE_RESPONSE_HEADERS: { 'Content-Type': 'text/event-stream' }, + startAbortPoller: mockStartAbortPoller, + unregisterActiveStream: mockUnregisterActiveStream, +})) + +vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) +vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) + +vi.mock('@/executor/utils/resolved-secret-trace-registry', () => ({ + ResolvedSecretTraceRegistry: class MockResolvedSecretTraceRegistry { + getModelEgressSnapshot() { + return { complete: true } + } + }, +})) + +vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId })) + +import { MAX_V2_CHAT_BODY_BYTES } from '@/lib/api/contracts/v2/chat' +import { POST } from '@/app/api/v2/chat/route' + +const RATE_LIMIT = { + allowed: true, + userId: 'key-owner-1', + keyType: 'personal' as const, + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-05T12:00:00.000Z'), +} + +const personalAttribution = { + actorUserId: 'key-owner-1', + workspaceId: 'workspace-1', + billedAccountUserId: 'payer-1', + organizationId: null, + billingEntity: { type: 'user' as const, id: 'payer-1' }, + billingPeriod: { + start: '2026-08-01T00:00:00.000Z', + end: '2026-09-01T00:00:00.000Z', + }, + payerSubscription: null, +} + +const systemAttribution = { + ...personalAttribution, + actorUserId: 'workspace-billed-account', +} + +function callChat(body: Record, headers: Record = {}) { + return POST( + createMockRequest( + 'POST', + body, + { 'Content-Type': 'application/json', 'x-api-key': 'caller-platform-key', ...headers }, + 'http://localhost:3000/api/v2/chat' + ) + ) +} + +function parseSse(stream: string): Record[] { + return stream + .split('\n') + .filter((line) => line.startsWith('data: ') && line !== 'data: [DONE]') + .map((line) => JSON.parse(line.slice('data: '.length)) as Record) +} + +describe('POST /api/v2/chat', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnv.COPILOT_API_KEY = 'deployment-mothership-key' + mockEnvFlags.isAuthDisabled = false + mockGenerateId + .mockReset() + .mockReturnValueOnce('message-1') + .mockReturnValueOnce('execution-1') + .mockReturnValueOnce('run-1') + .mockReturnValue('generated-extra') + mockResolveOrCreateChat.mockResolvedValue({ + chatId: 'chat-1', + chat: { id: 'chat-1', type: 'mothership', title: null }, + conversationHistory: [], + isNew: true, + }) + mockStreamWriter.mockImplementation(function MockStreamWriter() { + return { + close: mockPublisherClose, + flush: mockPublisherFlush, + publish: mockPublisherPublish, + sawComplete: false, + } + }) + mockIssueV2ChatContinuationToken.mockReturnValue('continuation-new') + mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValue(null) + mockVerifyV2ChatContinuationToken.mockReturnValue({ valid: false }) + mockPrepareV2ChatAttachments.mockReturnValue({ success: true, attachments: [] }) + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockResolveBillingAttribution.mockResolvedValue(personalAttribution) + mockResolveSystemBillingAttribution.mockResolvedValue(systemAttribution) + mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false }) + mockAcquirePendingChatStream.mockResolvedValue(true) + mockClearFilePreviewSessions.mockResolvedValue(undefined) + mockCleanupAbortMarker.mockResolvedValue(undefined) + mockCreateRunSegment.mockResolvedValue({ id: 'run-1' }) + mockFinalizeStream.mockResolvedValue(undefined) + mockPersistCopilotUserMessage.mockResolvedValue(undefined) + mockPublisherClose.mockResolvedValue(undefined) + mockPublisherFlush.mockResolvedValue(undefined) + mockReleasePendingChatStream.mockResolvedValue(undefined) + mockResetBuffer.mockResolvedValue(undefined) + mockRequestExplicitStreamAbort.mockResolvedValue(undefined) + mockScheduleBufferCleanup.mockResolvedValue(undefined) + mockScheduleFilePreviewSessionCleanup.mockResolvedValue(undefined) + mockStartAbortPoller.mockReturnValue(0) + mockRunWorkspaceChat.mockImplementation(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'Hello from Sim' }, + }) + return { + success: true, + content: 'Hello from Sim', + contentBlocks: [], + toolCalls: [], + usage: { prompt: 8, completion: 3 }, + } + }) + }) + + /** + * The one-off CLI turn (`sim chat ask`) is a command, not a conversation the + * workspace accumulates, so it must leave nothing for the chat list or + * `sim chats list` to surface — matching the Mothership block, which mints + * its own conversation id and never writes a chat row. The turn still gets a + * chat id and a continuation token, so the conversation remains continuable. + */ + it('creates no chat row when the caller opts out of persistence', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'What is here?', + persistChat: false, + }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockPersistCopilotUserMessage).not.toHaveBeenCalled() + expect(stream).toContain('"type":"complete"') + // No Sim-side row, so the token must not claim Sim persistence. + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.not.objectContaining({ persistence: 'sim' }) + ) + }) + + it('still persists the chat when the caller does not opt out', async () => { + await callChat({ workspaceId: 'workspace-1', prompt: 'What is here?' }) + expect(mockResolveOrCreateChat).toHaveBeenCalled() + }) + + it('requires persisted chat storage for asynchronous execution', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'What is here?', + async: true, + persistChat: false, + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: { + code: 'BAD_REQUEST', + message: 'Asynchronous chat requires persistChat to be true', + }, + }) + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('streams a personal-key chat and bills its authenticated actor', async () => { + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What is here?' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/event-stream') + expect(response.headers.get('x-ratelimit-remaining')).toBe('99') + expect(stream).toContain('"type":"session"') + expect(stream).toContain('"continuationToken":"continuation-new"') + expect(stream).toContain('"chatId":"chat-1"') + expect(stream).not.toContain('"runId":"run-1"') + expect(stream).toContain('"delta":"Hello from Sim"') + expect(stream).toContain('"type":"complete"') + expect(stream).toContain('data: [DONE]') + + expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ + actorUserId: 'key-owner-1', + workspaceId: 'workspace-1', + }) + expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'key-owner-1', + actorUserId: 'key-owner-1', + workspaceId: 'workspace-1', + billingAttribution: personalAttribution, + readOnly: false, + }) + ) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ + credentialType: 'personal', + readOnly: false, + persistence: 'sim', + }) + ) + expect(mockRunWorkspaceChat.mock.calls[0][0]).not.toHaveProperty('apiKey') + expect(mockAcquirePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') + expect(mockRegisterActiveStream).toHaveBeenCalledWith( + 'message-1', + expect.any(AbortController), + expect.any(AbortController) + ) + expect(mockStartAbortPoller).toHaveBeenCalledWith('message-1', expect.any(AbortController), { + requestId: 'request-1', + chatId: 'chat-1', + userStopController: expect.any(AbortController), + }) + expect(mockUnregisterActiveStream).toHaveBeenCalledWith('message-1') + expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') + expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') + expect(mockResolveOrCreateChat).toHaveBeenCalledWith({ + userId: 'key-owner-1', + workspaceId: 'workspace-1', + model: 'claude-opus-4-8', + type: 'mothership', + }) + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + type: 'created', + }) + expect(mockCreateRunSegment).toHaveBeenCalledWith({ + id: 'run-1', + executionId: 'execution-1', + chatId: 'chat-1', + userId: 'key-owner-1', + workspaceId: 'workspace-1', + streamId: 'message-1', + model: null, + requestContext: { requestId: 'request-1', source: 'v2_chat' }, + }) + expect(mockResetBuffer).toHaveBeenCalledWith('message-1') + expect(mockClearFilePreviewSessions).toHaveBeenCalledWith('message-1') + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith({ + chatId: 'chat-1', + userMessageId: 'message-1', + message: 'What is here?', + contexts: undefined, + workspaceId: 'workspace-1', + notifyWorkspaceStatus: true, + }) + expect(mockPublisherPublish).toHaveBeenCalledWith({ + type: 'session', + payload: { kind: 'chat', chatId: 'chat-1' }, + }) + expect(mockPublisherPublish).toHaveBeenCalledWith({ + type: 'text', + payload: { channel: 'assistant', text: 'Hello from Sim' }, + }) + expect(mockFinalizeStream).toHaveBeenCalledWith( + expect.objectContaining({ success: true, content: 'Hello from Sim' }), + expect.any(Object), + 'run-1', + 'success', + 'request-1' + ) + expect(mockFireTitleGeneration).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'chat-1', + isNewChat: true, + message: 'What is here?', + workspaceId: 'workspace-1', + }) + ) + /** + * Title generation projects its input against the secret-trace registry and + * fails closed when none is supplied, so omitting this silently skips every + * title on this route — the failure is a missing log line, not an error. + * The registry must also report complete, or the projection is still unsafe. + */ + const titleParams = mockFireTitleGeneration.mock.calls[0]![0] as { + resolvedSecretTraceRegistry?: { getModelEgressSnapshot(): { complete: boolean } } + } + expect(titleParams.resolvedSecretTraceRegistry).toBeDefined() + expect(titleParams.resolvedSecretTraceRegistry!.getModelEgressSnapshot().complete).toBe(true) + expect(mockPublisherClose).toHaveBeenCalledTimes(1) + expect(mockScheduleBufferCleanup).toHaveBeenCalledWith('message-1') + expect(mockScheduleFilePreviewSessionCleanup).toHaveBeenCalledWith('message-1') + }) + + it('passes validated resource and slash contexts to workspace chat', async () => { + const contexts = [ + { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, + { kind: 'skill', skillId: 'skill-1', label: 'review' }, + { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' }, + ] + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Use @Release and /review with /Docs', + contexts, + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith(expect.objectContaining({ contexts })) + }) + + it('rejects malformed or unsupported public context variants', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Use this', + contexts: [{ kind: 'folder', folderId: 'folder-1', label: 'Private folder' }], + }) + + expect(response.status).toBe(400) + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('fails with a retryable conflict before exposing a session when the chat lease is busy', async () => { + mockAcquirePendingChatStream.mockResolvedValueOnce(false) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: { + code: 'CONFLICT', + message: 'A response is already in progress for this chat', + }, + }) + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockRegisterActiveStream).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + expect(mockReleasePendingChatStream).not.toHaveBeenCalled() + }) + + it('does not issue a session token or start Mothership before the chat lease is acquired', async () => { + let acquire!: (value: boolean) => void + mockAcquirePendingChatStream.mockReturnValueOnce( + new Promise((resolve) => { + acquire = resolve + }) + ) + + const pendingResponse = callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + + acquire(true) + const response = await pendingResponse + const stream = await response.text() + expect(stream).toContain('"type":"session"') + expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1) + }) + + it('does not expose the continuation token until Go accepts the initial stream', async () => { + let accept!: () => void + let settle!: () => void + mockFireTitleGeneration.mockImplementationOnce( + ({ publisher }: { publisher: { publish: (event: unknown) => void } }) => { + publisher.publish({ + type: 'session', + payload: { kind: 'title', title: 'Release investigation' }, + }) + } + ) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + accept = () => input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + const reader = response.body!.getReader() + let firstReadSettled = false + const firstRead = reader.read().then((result) => { + firstReadSettled = true + return result + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(firstReadSettled).toBe(false) + + accept() + const first = await firstRead + const acceptedSession = new TextDecoder().decode(first.value) + expect(acceptedSession).toContain('"type":"session"') + expect(acceptedSession).toContain('"continuationToken":"continuation-new"') + expect(acceptedSession).toContain('"title":"Release investigation"') + expect(mockPublisherPublish).toHaveBeenCalledWith({ + type: 'session', + payload: { kind: 'title', title: 'Release investigation' }, + }) + + settle() + while (!(await reader.read()).done) { + // Drain the completion so route cleanup can release its lease. + } + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + }) + + it('projects a title generated after session acceptance onto the public stream', async () => { + let publishTitle!: (event: unknown) => void + mockFireTitleGeneration.mockImplementationOnce( + ({ publisher }: { publisher: { publish: (event: unknown) => void } }) => { + publishTitle = publisher.publish + } + ) + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + publishTitle({ + type: 'session', + payload: { kind: 'title', title: 'Deployment failure' }, + }) + return { + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What failed?' }) + const events = parseSse(await response.text()) + + expect(events).toContainEqual({ + type: 'session', + chatId: 'chat-1', + title: 'Deployment failure', + }) + }) + + it('does not hold a synchronous Go leg on run creation but waits before finalizing it', async () => { + let resolveRunSegment!: () => void + let resolveChat!: () => void + mockCreateRunSegment.mockReturnValueOnce( + new Promise((resolve) => { + resolveRunSegment = () => resolve({ id: 'run-1' }) + }) + ) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + input.onInitialStreamAccepted?.() + resolveChat = () => + resolve({ + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Continue' }) + await vi.waitFor(() => expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1)) + + resolveChat() + await new Promise((resolve) => setImmediate(resolve)) + expect(mockFinalizeStream).not.toHaveBeenCalled() + + resolveRunSegment() + expect(await response.text()).toContain('"type":"complete"') + expect(mockFinalizeStream).toHaveBeenCalledTimes(1) + }) + + it('creates an asynchronous run durably before starting Go or exposing its session', async () => { + let resolveRunSegment!: () => void + mockCreateRunSegment.mockReturnValueOnce( + new Promise((resolve) => { + resolveRunSegment = () => resolve({ id: 'run-1' }) + }) + ) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Continue', + async: true, + }) + const reader = response.body!.getReader() + let firstReadSettled = false + const firstRead = reader.read().then((result) => { + firstReadSettled = true + return result + }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + expect(firstReadSettled).toBe(false) + + resolveRunSegment() + const first = await firstRead + expect(new TextDecoder().decode(first.value)).toContain('"runId":"run-1"') + while (!(await reader.read()).done) { + // Drain the completion so route cleanup can release its lease. + } + expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1) + expect(mockFinalizeStream).toHaveBeenCalledTimes(1) + }) + + it('keeps a synchronous synced turn working when run creation fails', async () => { + mockCreateRunSegment.mockRejectedValueOnce(new Error('run table unavailable')) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Continue' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(stream).toContain('"type":"complete"') + expect(stream).not.toContain('"runId":"run-1"') + expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1) + expect(mockFinalizeStream).toHaveBeenCalledTimes(1) + }) + + it('fails before acceptance when durable run creation fails', async () => { + mockCreateRunSegment.mockRejectedValueOnce(new Error('run table unavailable')) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Continue', + async: true, + }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(stream).not.toContain('"type":"session"') + expect(stream).toContain('"code":"INTERNAL_ERROR"') + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + expect(mockFinalizeStream).not.toHaveBeenCalled() + }) + + it('surfaces a pre-acceptance failure without exposing a continuation token', async () => { + mockRunWorkspaceChat.mockResolvedValueOnce({ + success: false, + content: '', + contentBlocks: [], + toolCalls: [], + error: 'workspace setup failed', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + const stream = await response.text() + + expect(stream).not.toContain('"type":"session"') + expect(stream).toContain('"code":"INTERNAL_ERROR"') + }) + + it('enables the subtractive query policy only when explicitly requested', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Only inspect this workspace', + readOnly: true, + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith(expect.objectContaining({ readOnly: true })) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ credentialType: 'personal', readOnly: true }) + ) + }) + + it('continues a legacy Go-only chat without exposing or partially persisting it', async () => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ + valid: true, + chatId: 'private-chat-id', + }) + mockIssueV2ChatContinuationToken.mockReturnValueOnce('continuation-refreshed') + mockGenerateId.mockReset().mockReturnValue('message-followup') + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Tell me more', + continuationToken: 'continuation-old', + }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(mockVerifyV2ChatContinuationToken).toHaveBeenCalledWith('continuation-old', { + workspaceId: 'workspace-1', + authorizationUserId: 'key-owner-1', + credentialType: 'personal', + readOnly: false, + }) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith({ + chatId: 'private-chat-id', + workspaceId: 'workspace-1', + authorizationUserId: 'key-owner-1', + credentialType: 'personal', + readOnly: false, + }) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'private-chat-id', + messageId: 'message-followup', + }) + ) + expect(stream).toContain('"continuationToken":"continuation-refreshed"') + expect(stream).not.toContain('private-chat-id') + expect(mockGetAccessibleCopilotChatContinuationMetadata).toHaveBeenCalledWith( + 'private-chat-id', + 'key-owner-1' + ) + expect(mockStreamWriter).not.toHaveBeenCalled() + expect(mockPersistCopilotUserMessage).not.toHaveBeenCalled() + expect(mockPublishStatusChanged).not.toHaveBeenCalled() + }) + + it('rejects asynchronous continuation of a legacy Go-only chat', async () => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ + valid: true, + chatId: 'private-chat-id', + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Tell me more', + continuationToken: 'continuation-old', + async: true, + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: { + code: 'BAD_REQUEST', + message: 'Asynchronous chat requires a persisted chat', + }, + }) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockCreateRunSegment).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('continues an existing persisted personal chat with UI replay enabled', async () => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ + valid: true, + chatId: 'shared-chat-1', + }) + mockIssueV2ChatContinuationToken.mockReturnValueOnce('continuation-refreshed') + mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValueOnce({ + id: 'shared-chat-1', + userId: 'key-owner-1', + workflowId: null, + workspaceId: 'workspace-1', + type: 'mothership', + title: 'Existing chat', + hasMessages: true, + mcpServerIds: ['mcp-history'], + }) + mockGenerateId + .mockReset() + .mockReturnValueOnce('message-followup') + .mockReturnValueOnce('execution-followup') + .mockReturnValueOnce('run-followup') + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Continue', + continuationToken: 'continuation-old', + contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], + }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(stream).toContain('"chatId":"shared-chat-1"') + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'shared-chat-1', + userMessageId: 'message-followup', + message: 'Continue', + contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], + }) + ) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServerIds: ['mcp-history'], + contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], + }) + ) + expect(mockCreateRunSegment).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'run-followup', + executionId: 'execution-followup', + chatId: 'shared-chat-1', + }) + ) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ chatId: 'shared-chat-1', persistence: 'sim' }) + ) + }) + + it.each([ + ['missing or deleted', null], + [ + 'the wrong type', + { + id: 'synced-chat-1', + userId: 'key-owner-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + type: 'copilot', + title: 'Workflow chat', + hasMessages: true, + }, + ], + [ + 'from another workspace', + { + id: 'synced-chat-1', + userId: 'key-owner-1', + workflowId: null, + workspaceId: 'workspace-2', + type: 'mothership', + title: 'Other workspace', + hasMessages: true, + }, + ], + ])('rejects an explicitly Sim-persisted continuation when its row is %s', async (_case, chat) => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ + valid: true, + chatId: 'synced-chat-1', + persistence: 'sim', + }) + mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValueOnce(chat) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Continue', + continuationToken: 'continuation-sim', + }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat not found' }, + }) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('fails closed before billing or Mothership for an invalid continuation token', async () => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ valid: false }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'steal history', + continuationToken: 'tampered-or-cross-owner-token', + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: { code: 'BAD_REQUEST', message: 'Invalid or expired continuation token' }, + }) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('validates inline attachments and forwards only the server-mapped Mothership shape', async () => { + const publicAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'aGk=', + } + const mothershipAttachment = { + type: 'document', + filename: 'notes.txt', + source: { type: 'base64', media_type: 'text/plain', data: 'aGk=' }, + } + mockPrepareV2ChatAttachments.mockReturnValueOnce({ + success: true, + attachments: [mothershipAttachment], + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Read this', + attachments: [publicAttachment], + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockPrepareV2ChatAttachments).toHaveBeenCalledWith([publicAttachment]) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ fileAttachments: [mothershipAttachment] }) + ) + }) + + it('normalizes an attachment-only turn to a neutral upstream prompt', async () => { + mockPrepareV2ChatAttachments.mockReturnValueOnce({ + success: true, + attachments: [ + { + type: 'document', + filename: 'notes.txt', + source: { type: 'base64', media_type: 'text/plain', data: 'aGk=' }, + }, + ], + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: ' ', + attachments: [{ name: 'notes.txt', mediaType: 'text/plain', data: 'aGk=' }], + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ prompt: 'Please inspect the attached file(s).' }) + ) + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Please inspect the attached file(s).' }) + ) + }) + + it('returns a typed HTTP error before billing when attachment validation fails', async () => { + mockPrepareV2ChatAttachments.mockReturnValueOnce({ + success: false, + error: { + code: 'UNSUPPORTED_MEDIA_TYPE', + message: 'Attachment "clip.mp4" has unsupported media type video/mp4', + }, + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Watch this', + attachments: [{ name: 'clip.mp4', mediaType: 'video/mp4', data: 'AAAA' }], + }) + + expect(response.status).toBe(415) + expect((await response.json()).error.code).toBe('UNSUPPORTED_MEDIA_TYPE') + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('returns the v2 payload-too-large envelope for an oversized raw body', async () => { + const response = await callChat( + { workspaceId: 'workspace-1', prompt: 'hello' }, + { 'Content-Length': String(MAX_V2_CHAT_BODY_BYTES + 1) } + ) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: { + code: 'PAYLOAD_TOO_LARGE', + message: `Request body exceeds the ${MAX_V2_CHAT_BODY_BYTES}-byte limit`, + }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('forwards Mothership text events as deltas without prefix guessing', async () => { + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'a' }, + }) + // This delta starts with all prior output. Treating events as possibly + // cumulative would incorrectly emit only "bc" here. + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'abc' }, + }) + return { + success: true, + content: 'aabc', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(stream).toContain('"delta":"a"') + expect(stream).toContain('"delta":"abc"') + expect(stream).not.toContain('"delta":"bc"') + }) + + it('projects scoped assistant narration without merging it into the public answer', async () => { + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch', + spanId: 'private-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'start', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'text', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch', + spanId: 'private-span', + parentSpanId: 'main', + }, + payload: { channel: 'assistant', text: 'Scoped progress.' }, + }) + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch', + spanId: 'private-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'end', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'public final delta' }, + }) + return { + success: true, + content: 'public final delta', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + const events = parseSse(stream) + const activities = events.filter((event) => event.type === 'activity') + const answerText = events.filter((event) => event.type === 'text') + + expect(answerText).toEqual([{ type: 'text', delta: 'public final delta' }]) + expect(activities).toEqual([ + { + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Research Agent', + state: 'running', + }, + }, + { + type: 'activity', + data: { kind: 'narration', parentId: 'agent-1', delta: 'Scoped progress.' }, + }, + { + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Research Agent', + state: 'complete', + }, + }, + ]) + expect(stream).not.toContain('private-dispatch') + expect(stream).not.toContain('private-span') + }) + + it('projects a display-safe nested activity tree without private stream data', async () => { + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'text', + payload: { channel: 'thinking', text: 'Inspecting the workspace' }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'call', + toolCallId: 'private-tool-id', + toolName: 'read', + arguments: { secret: 'never-forward-me' }, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'call', + toolCallId: 'hidden-tool-id', + toolName: 'private_hidden_tool', + arguments: { secret: 'hidden-call-secret' }, + executor: 'sim', + mode: 'async', + ui: { hidden: true }, + }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'result', + toolCallId: 'hidden-tool-id', + toolName: 'private_hidden_tool', + output: { secret: 'hidden-result-secret' }, + success: true, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'tool', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { + phase: 'call', + toolCallId: 'scoped-tool-id', + toolName: 'private_scoped_tool', + arguments: { secret: 'scoped-secret' }, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'tool', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { + phase: 'result', + toolCallId: 'scoped-tool-id', + toolName: 'private_scoped_tool', + output: { secret: 'scoped-result-secret' }, + success: true, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'start', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'text', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { channel: 'thinking', text: 'private subagent reasoning' }, + }) + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'end', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'result', + toolCallId: 'private-tool-id', + toolName: 'read', + output: { secret: 'never-forward-me' }, + success: true, + executor: 'sim', + mode: 'async', + }, + }) + return { + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(stream).toContain('"type":"complete"') + expect(stream).toContain('Done') + expect(stream).toContain('"type":"activity"') + expect(stream).toContain('"label":"Reading file"') + expect(stream).toContain('"label":"Read file"') + expect(stream).toContain('"label":"Research Agent"') + expect(stream).toContain('"label":"Private Scoped Tool"') + expect(stream).toContain('"parentId":"agent-1"') + expect(stream).toContain('"state":"running"') + expect(stream).toContain('"state":"complete"') + expect(stream.match(/"type":"activity"/g)).toHaveLength(6) + expect(stream).not.toContain('Inspecting the workspace') + expect(stream).not.toContain('private-tool-id') + expect(stream).not.toContain('private_hidden_tool') + expect(stream).not.toContain('private_scoped_tool') + expect(stream).not.toContain('private-research-span') + expect(stream).not.toContain('never-forward-me') + expect(stream).not.toContain('scoped-secret') + expect(stream).not.toContain('scoped-result-secret') + expect(stream).not.toContain('private subagent reasoning') + }) + + it('authorizes a workspace key as its creator but executes and bills as the system actor', async () => { + mockGenerateId + .mockReset() + .mockReturnValueOnce('chat-1') + .mockReturnValueOnce('message-1') + .mockReturnValue('generated-extra') + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Summarize it' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'key-owner-1', keyType: 'workspace' }), + 'key-owner-1', + 'workspace-1', + 'read' + ) + expect(mockResolveSystemBillingAttribution).toHaveBeenCalledWith('workspace-1') + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ credentialType: 'workspace', readOnly: false }) + ) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'key-owner-1', + actorUserId: 'workspace-billed-account', + billingAttribution: systemAttribution, + sharedWorkspaceCredential: true, + }) + ) + expect(stream).not.toContain('"chatId":"chat-1"') + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockStreamWriter).not.toHaveBeenCalled() + expect(mockPersistCopilotUserMessage).not.toHaveBeenCalled() + expect(mockPublishStatusChanged).not.toHaveBeenCalled() + }) + + it('requires a personal API key for asynchronous execution', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Summarize it', + async: true, + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Asynchronous chat requires a personal API key', + }, + }) + expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('routes a workspace-key abort by its owner while preserving the billing actor body', async () => { + mockGenerateId + .mockReset() + .mockReturnValueOnce('chat-1') + .mockReturnValueOnce('message-1') + .mockReturnValue('generated-extra') + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + mockRunWorkspaceChat.mockResolvedValueOnce({ + success: false, + content: '', + contentBlocks: [], + toolCalls: [], + error: 'upstream failed', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Summarize it' }) + await response.text() + + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'key-owner-1', + actorUserId: 'workspace-billed-account', + billingAttribution: systemAttribution, + }) + ) + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ + streamId: 'message-1', + userId: 'workspace-billed-account', + routingUserId: 'key-owner-1', + chatId: 'chat-1', + workspaceId: 'workspace-1', + }) + }) + + it('supports the auth-disabled self-host principal while keeping upstream auth server-owned', async () => { + const anonymousAttribution = { + ...personalAttribution, + actorUserId: 'anonymous', + } + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + userId: 'anonymous', + keyType: 'personal', + }) + mockEnvFlags.isAuthDisabled = true + mockResolveBillingAttribution.mockResolvedValue(anonymousAttribution) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What is here?' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'anonymous', keyType: undefined }), + 'anonymous', + 'workspace-1', + 'read' + ) + expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ + actorUserId: 'anonymous', + workspaceId: 'workspace-1', + }) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'anonymous', + actorUserId: 'anonymous', + billingAttribution: anonymousAttribution, + }) + ) + expect(stream).toContain('"chatId":"chat-1"') + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ chatId: 'chat-1', message: 'What is here?' }) + ) + }) + + it('returns 402 before opening a stream or calling Mothership when usage is exhausted', async () => { + mockCheckAttributedUsageLimits.mockResolvedValue({ + isExceeded: true, + message: 'Organization usage limit exceeded', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + + expect(response.status).toBe(402) + expect(await response.json()).toEqual({ + error: { code: 'USAGE_LIMIT_EXCEEDED', message: 'Organization usage limit exceeded' }, + }) + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('surfaces a raced or self-hosted upstream 402 as a structured stream error', async () => { + const upgrade = + '{"reason":"usage_limit","action":"increase_limit","message":"Ask an org admin."}' + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: upgrade }, + }) + return { + success: true, + content: upgrade, + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(stream).toContain('"code":"USAGE_LIMIT_EXCEEDED"') + expect(stream).toContain('Ask an org admin.') + expect(stream).not.toContain('') + expect(stream).not.toContain('"type":"complete"') + }) + + it('rejects a cross-workspace key before resolving a payer', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'API key is not authorized for this workspace', + }) + + const response = await callChat({ workspaceId: 'workspace-2', prompt: 'hello' }) + + expect(response.status).toBe(403) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('returns a clear 503 when the deployment has no Mothership key', async () => { + mockEnv.COPILOT_API_KEY = undefined + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + error: { + code: 'SERVICE_UNAVAILABLE', + message: 'Sim Chat is not configured on this deployment', + }, + }) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + }) + + it('does not leak an upstream failure body and explicitly stops detached generation', async () => { + mockRunWorkspaceChat.mockResolvedValueOnce({ + success: false, + content: '', + contentBlocks: [], + toolCalls: [], + error: 'upstream secret response body', + errors: ['provider internal detail'], + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(stream).toContain('"code":"INTERNAL_ERROR"') + expect(stream).toContain('"message":"Chat request failed"') + expect(stream).not.toContain('upstream secret response body') + expect(stream).not.toContain('provider internal detail') + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1) + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ + streamId: 'message-1', + userId: 'key-owner-1', + routingUserId: 'key-owner-1', + chatId: 'chat-1', + workspaceId: 'workspace-1', + }) + }) + + it('rejects caller-controlled identity, model, and provider fields', async () => { + for (const forbidden of [ + { userId: 'forged-user' }, + { model: 'caller-model' }, + { provider: 'caller-provider' }, + { chatId: 'raw-private-chat-id' }, + { conversationId: 'raw-private-chat-id' }, + ]) { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'hello', + ...forbidden, + }) + expect(response.status).toBe(400) + } + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('returns the shared v2 auth error before parsing the body', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 0, + remaining: 0, + resetAt: new Date(), + error: 'Invalid API key', + }) + + const response = await callChat({}) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + expect(mockV2ApiGateError).not.toHaveBeenCalled() + }) + + it('stops local work, marks Go once, and retains the lease until the lifecycle settles', async () => { + const teardownOrder: string[] = [] + let settle!: () => void + let lifecycleSignal: AbortSignal | undefined + let userStopSignal: AbortSignal | undefined + mockRequestExplicitStreamAbort.mockImplementationOnce(async () => { + teardownOrder.push('go-abort') + }) + mockReleasePendingChatStream.mockImplementationOnce(async () => { + teardownOrder.push('release') + }) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + lifecycleSignal = input.abortSignal + userStopSignal = input.userStopSignal + input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: false, + cancelled: true, + content: '', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + const request = new NextRequest('http://localhost:3000/api/v2/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'caller-platform-key', + }, + body: JSON.stringify({ workspaceId: 'workspace-1', prompt: 'keep going' }), + }) + + const response = await POST(request) + const reader = response.body!.getReader() + await reader.read() + await reader.cancel('test_disconnect') + + await vi.waitFor(() => expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1)) + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ + streamId: 'message-1', + userId: 'key-owner-1', + routingUserId: 'key-owner-1', + chatId: 'chat-1', + workspaceId: 'workspace-1', + }) + expect(lifecycleSignal?.aborted).toBe(false) + expect(userStopSignal?.aborted).toBe(true) + expect(userStopSignal?.reason).toBe('user_stop:abortActiveStream') + expect(mockReleasePendingChatStream).not.toHaveBeenCalled() + + settle() + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + expect(teardownOrder).toEqual(['go-abort', 'release']) + expect(mockUnregisterActiveStream).toHaveBeenCalledTimes(1) + expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') + }) + + it('keeps an accepted asynchronous turn running after its reader disconnects', async () => { + let settle!: () => void + let lifecycleSignal: AbortSignal | undefined + let userStopSignal: AbortSignal | undefined + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + lifecycleSignal = input.abortSignal + userStopSignal = input.userStopSignal + input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: true, + content: 'Finished in the background', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'keep going', + async: true, + }) + const reader = response.body!.getReader() + const acceptedSession = new TextDecoder().decode((await reader.read()).value) + expect(acceptedSession).toContain('"runId":"run-1"') + + await reader.cancel('async_receipt_received') + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockRequestExplicitStreamAbort).not.toHaveBeenCalled() + expect(lifecycleSignal?.aborted).toBe(false) + expect(userStopSignal?.aborted).toBe(false) + expect(mockReleasePendingChatStream).not.toHaveBeenCalled() + + settle() + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + expect(mockFinalizeStream).toHaveBeenCalledWith( + expect.objectContaining({ success: true, content: 'Finished in the background' }), + expect.any(Object), + 'run-1', + 'success', + 'request-1' + ) + }) + + it('does not classify an accepted asynchronous turn as cancelled when its request aborts', async () => { + const requestAbortController = new AbortController() + let settle!: () => void + let userStopSignal: AbortSignal | undefined + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + userStopSignal = input.userStopSignal + input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: true, + content: 'Finished after request disconnect', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + const request = new NextRequest('http://localhost:3000/api/v2/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'caller-platform-key', + }, + body: JSON.stringify({ + workspaceId: 'workspace-1', + prompt: 'keep going', + async: true, + }), + signal: requestAbortController.signal, + }) + + const response = await POST(request) + const reader = response.body!.getReader() + expect(new TextDecoder().decode((await reader.read()).value)).toContain('"runId":"run-1"') + + requestAbortController.abort() + await new Promise((resolve) => setImmediate(resolve)) + expect(mockRequestExplicitStreamAbort).not.toHaveBeenCalled() + expect(userStopSignal?.aborted).toBe(false) + + settle() + while (!(await reader.read()).done) { + // Drain the completion so route cleanup can release its lease. + } + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + expect(mockFinalizeStream).toHaveBeenCalledWith( + expect.objectContaining({ success: true, content: 'Finished after request disconnect' }), + expect.any(Object), + 'run-1', + 'success', + 'request-1' + ) + }) + + it('still stops an asynchronous turn when the reader disconnects before acceptance', async () => { + let settle!: () => void + let userStopSignal: AbortSignal | undefined + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + userStopSignal = input.userStopSignal + settle = () => + resolve({ + success: false, + cancelled: true, + content: '', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'keep going', + async: true, + }) + await vi.waitFor(() => expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1)) + await response.body!.cancel('pre_accept_disconnect') + + await vi.waitFor(() => expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1)) + expect(userStopSignal?.aborted).toBe(true) + expect(userStopSignal?.reason).toBe('user_stop:abortActiveStream') + + settle() + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + expect(mockFinalizeStream).toHaveBeenCalledWith( + expect.objectContaining({ cancelled: true }), + expect.any(Object), + 'run-1', + 'cancelled', + 'request-1' + ) + }) + + it('does not start a lifecycle when Stop wins before workspace chat begins', async () => { + mockRegisterActiveStream.mockImplementationOnce( + ( + _streamId: string, + _lifecycleController: AbortController, + userStopController: AbortController + ) => userStopController.abort('user_stop:abortActiveStream') + ) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'keep going' }) + expect(await response.text()).toBe('') + + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + expect(mockUnregisterActiveStream).toHaveBeenCalledWith('message-1') + expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') + expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') + }) + + it('still stops local work and retains the lease when the Go abort marker fails', async () => { + let settle!: () => void + let lifecycleSignal: AbortSignal | undefined + let userStopSignal: AbortSignal | undefined + mockRequestExplicitStreamAbort.mockRejectedValueOnce(new Error('marker unavailable')) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + lifecycleSignal = input.abortSignal + userStopSignal = input.userStopSignal + input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: true, + content: 'settled naturally', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const request = new NextRequest('http://localhost:3000/api/v2/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'caller-platform-key', + }, + body: JSON.stringify({ workspaceId: 'workspace-1', prompt: 'keep going' }), + }) + const response = await POST(request) + const reader = response.body!.getReader() + await reader.read() + await reader.cancel('test_disconnect') + + await vi.waitFor(() => expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1)) + expect(lifecycleSignal?.aborted).toBe(false) + expect(userStopSignal?.aborted).toBe(true) + expect(userStopSignal?.reason).toBe('user_stop:abortActiveStream') + expect(mockReleasePendingChatStream).not.toHaveBeenCalled() + + settle() + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts new file mode 100644 index 00000000000..e4671df3fba --- /dev/null +++ b/apps/sim/app/api/v2/chat/route.ts @@ -0,0 +1,755 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { MAX_V2_CHAT_BODY_BYTES, v2ChatContract } from '@/lib/api/contracts/v2/chat' +import { parseRequest } from '@/lib/api/server' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { createRunSegment } from '@/lib/copilot/async-runs/repository' +import { + getAccessibleCopilotChatContinuationMetadata, + resolveOrCreateChat, +} from '@/lib/copilot/chat/lifecycle' +import { ChatActivityProjector, type V2ChatActivity } from '@/lib/copilot/chat/public-activity' +import { + buildCopilotTurnOnComplete, + buildCopilotTurnOnError, + persistCopilotUserMessage, +} from '@/lib/copilot/chat/turn-persistence' +import { chatPubSub } from '@/lib/copilot/chat-status' +import { + MothershipStreamV1EventType, + MothershipStreamV1SessionKind, + MothershipStreamV1TextChannel, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { RequestTraceV1Outcome } from '@/lib/copilot/generated/request-trace-v1' +import { prepareV2ChatAttachments } from '@/lib/copilot/headless/attachments' +import { + issueV2ChatContinuationToken, + verifyV2ChatContinuationToken, +} from '@/lib/copilot/headless/continuation-token' +import { + publicChatUsageLimitMessage, + runWorkspaceChat, + toPublicChatResult, +} from '@/lib/copilot/headless/workspace-chat' +import { finalizeStream } from '@/lib/copilot/request/lifecycle/finalize' +import { fireTitleGeneration } from '@/lib/copilot/request/lifecycle/start' +import { + AbortReason, + acquirePendingChatStream, + cleanupAbortMarker, + clearFilePreviewSessions, + encodeSSEComment, + encodeSSEEnvelope, + registerActiveStream, + releasePendingChatStream, + resetBuffer, + SSE_RESPONSE_HEADERS, + StreamWriter, + scheduleBufferCleanup, + scheduleFilePreviewSessionCleanup, + startAbortPoller, + unregisterActiveStream, +} from '@/lib/copilot/request/session' +import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' +import type { OrchestratorResult } from '@/lib/copilot/request/types' +import { env } from '@/lib/core/config/env' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + rateLimitHeaders, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +export const maxDuration = 3600 +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const logger = createLogger('V2ChatAPI') +const encoder = new TextEncoder() +const HEARTBEAT_INTERVAL_MS = 15_000 +const ATTACHMENT_ONLY_PROMPT = 'Please inspect the attached file(s).' +const V2_CHAT_TITLE_MODEL = 'claude-opus-4-8' + +/** + * An empty, complete secret-trace registry for title generation. + * + * `projectResolvedSecretModelContent` fails closed on a missing registry, so + * without one every title on this route is skipped. The empty registry is the + * accurate claim rather than a bypass: the title is generated from + * `effectivePrompt`, which is the request body's own `prompt` verbatim — this + * route runs no workflow and resolves no secrets into it, so there is nothing + * for the matcher to redact. Shared because it is immutable and the matcher + * cache is keyed on the instance. + * + * If this route ever resolves secrets into the prompt, thread that execution's + * real registry through here instead. + */ +const V2_CHAT_TITLE_SECRET_REGISTRY = new ResolvedSecretTraceRegistry([]) + +interface SyncedChat { + chat: { title?: string | null } | null + isNewChat: boolean + mcpServerIds: string[] +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} + +/** POST /api/v2/chat — normal workspace chat with opaque continuation over SSE. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + let acquiredChatId: string | undefined + let acquiredStreamId: string | undefined + let streamOwnsLock = false + + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const authenticatedUserId = rateLimit.userId! + const gate = await v2ApiGateError(authenticatedUserId) + if (gate) return gate + + const parsed = await parseRequest( + v2ChatContract, + request, + {}, + { + maxBodyBytes: MAX_V2_CHAT_BODY_BYTES, + validationErrorResponse: v2ValidationError, + invalidJsonResponse: () => + v2Error('BAD_REQUEST', 'Request body must be valid JSON', { + headers: rateLimitHeaders(rateLimit), + }), + } + ) + if (!parsed.success) { + return parsed.response.status === 413 + ? v2Error( + 'PAYLOAD_TOO_LARGE', + `Request body exceeds the ${MAX_V2_CHAT_BODY_BYTES}-byte limit`, + { headers: rateLimitHeaders(rateLimit) } + ) + : parsed.response + } + + const { + workspaceId, + prompt, + continuationToken, + readOnly, + async: asyncRequested, + attachments, + contexts, + persistChat, + } = parsed.data.body + const credentialType = rateLimit.keyType === 'workspace' ? 'workspace' : 'personal' + const effectivePrompt = prompt.trim() ? prompt : ATTACHMENT_ONLY_PROMPT + // DISABLE_AUTH produces an anonymous pseudo-personal principal. It is not + // an API key, so the workspace's personal-key toggle must not reject it. + // Real personal keys retain the normal toggle on every hosted path. + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess( + accessPrincipal, + authenticatedUserId, + workspaceId, + 'read' + ) + if (access) return v2WorkspaceAccessError(access) + + // Sim API keys authenticate this public boundary only. Every Sim -> Go + // request uses the deployment-owned key so hosted and self-hosted billing + // semantics cannot be changed by a caller-controlled credential. + if (!env.COPILOT_API_KEY?.trim()) { + return v2Error('SERVICE_UNAVAILABLE', 'Sim Chat is not configured on this deployment', { + headers: rateLimitHeaders(rateLimit), + }) + } + + if (asyncRequested && rateLimit.keyType !== 'personal') { + return v2Error('FORBIDDEN', 'Asynchronous chat requires a personal API key', { + headers: rateLimitHeaders(rateLimit), + }) + } + if (asyncRequested && !persistChat) { + return v2Error('BAD_REQUEST', 'Asynchronous chat requires persistChat to be true', { + headers: rateLimitHeaders(rateLimit), + }) + } + + const continuation = continuationToken + ? await verifyV2ChatContinuationToken(continuationToken, { + workspaceId, + authorizationUserId: authenticatedUserId, + credentialType, + readOnly, + }) + : null + if (continuation && !continuation.valid) { + return v2Error('BAD_REQUEST', 'Invalid or expired continuation token', { + headers: rateLimitHeaders(rateLimit), + }) + } + + const shouldSyncChat = rateLimit.keyType === 'personal' + let continuedSyncedChat: SyncedChat | null = null + if (continuation?.valid) { + if (continuation.persistence === 'sim' && !shouldSyncChat) { + return v2Error('NOT_FOUND', 'Chat not found', { + headers: rateLimitHeaders(rateLimit), + }) + } + if (shouldSyncChat) { + const existing = await getAccessibleCopilotChatContinuationMetadata( + continuation.chatId, + authenticatedUserId + ) + const matchesPersistedChat = + existing?.type === 'mothership' && existing.workspaceId === workspaceId + /** + * Tokens issued before Sim-side persistence can point at a Go-only + * chat. A deleted/missing row follows the same path: keep the valid + * continuation working, but do not create a partial UI transcript + * without its earlier turns. + */ + if (matchesPersistedChat && existing) { + continuedSyncedChat = { + chat: { title: existing.title }, + isNewChat: !existing.hasMessages, + mcpServerIds: existing.mcpServerIds, + } + } else if (continuation.persistence === 'sim') { + return v2Error('NOT_FOUND', 'Chat not found', { + headers: rateLimitHeaders(rateLimit), + }) + } + } + } + if (asyncRequested && continuation?.valid && !continuedSyncedChat) { + return v2Error('BAD_REQUEST', 'Asynchronous chat requires a persisted chat', { + headers: rateLimitHeaders(rateLimit), + }) + } + + const preparedAttachments = prepareV2ChatAttachments(attachments) + if (!preparedAttachments.success) { + return v2Error(preparedAttachments.error.code, preparedAttachments.error.message, { + headers: rateLimitHeaders(rateLimit), + }) + } + + /** + * Match public workflow execution: a personal key identifies its human + * actor; a shared workspace key uses the atomically resolved system actor + * and payer. Authorization above always remains bound to the key owner. + */ + const billingAttribution = + rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(workspaceId) + : await resolveBillingAttribution({ actorUserId: authenticatedUserId, workspaceId }) + const actorUserId = billingAttribution.actorUserId + + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', + { headers: rateLimitHeaders(rateLimit) } + ) + } + + let syncedChat = continuedSyncedChat + let chatId: string + + if (continuation?.valid) { + chatId = continuation.chatId + } else if (shouldSyncChat && persistChat) { + /* `persistChat: false` gates chat *creation* only. It deliberately does + not reach the continuation branch above: detaching a chat that is + already persisted would silently drop the rest of its transcript. */ + const created = await resolveOrCreateChat({ + userId: authenticatedUserId, + workspaceId, + model: V2_CHAT_TITLE_MODEL, + type: 'mothership', + }) + if (!created.chat || !created.chatId) { + throw new Error('Failed to create persisted v2 chat') + } + syncedChat = { + chat: created.chat, + isNewChat: created.conversationHistory.length === 0, + mcpServerIds: [], + } + chatId = created.chatId + chatPubSub?.publishStatusChanged({ workspaceId, chatId, type: 'created' }) + } else { + chatId = generateId() + } + + const messageId = generateId() + const executionId = syncedChat ? generateId() : undefined + const runId = syncedChat ? generateId() : undefined + const replayPublisher = syncedChat + ? new StreamWriter({ streamId: messageId, chatId, requestId }) + : null + const onTurnComplete = syncedChat + ? buildCopilotTurnOnComplete({ + chatId, + userMessageId: messageId, + requestId, + workspaceId, + notifyWorkspaceStatus: true, + }) + : undefined + const onTurnError = syncedChat + ? buildCopilotTurnOnError({ + chatId, + userMessageId: messageId, + requestId, + workspaceId, + notifyWorkspaceStatus: true, + }) + : undefined + const lifecycleAbortController = new AbortController() + const userStopController = new AbortController() + const chatStreamLockAcquired = await acquirePendingChatStream(chatId, messageId) + if (!chatStreamLockAcquired) { + return v2Error('CONFLICT', 'A response is already in progress for this chat', { + headers: rateLimitHeaders(rateLimit), + }) + } + acquiredChatId = chatId + acquiredStreamId = messageId + if (request.signal.aborted) { + await releasePendingChatStream(chatId, messageId) + acquiredChatId = undefined + acquiredStreamId = undefined + return v2Error('CLIENT_CLOSED_REQUEST', 'Chat request cancelled', { + headers: rateLimitHeaders(rateLimit), + }) + } + + const refreshedContinuationToken = await issueV2ChatContinuationToken({ + chatId, + workspaceId, + authorizationUserId: authenticatedUserId, + credentialType, + readOnly, + ...(syncedChat ? { persistence: 'sim' as const } : {}), + }) + if (request.signal.aborted) { + await releasePendingChatStream(chatId, messageId) + acquiredChatId = undefined + acquiredStreamId = undefined + return v2Error('CLIENT_CLOSED_REQUEST', 'Chat request cancelled', { + headers: rateLimitHeaders(rateLimit), + }) + } + let cancelled = false + let publicStreamOpen = false + let lifecycleStarted = false + let abortRequested = false + let allowExplicitAbort = true + let sessionAccepted = false + let explicitAbortRequest: Promise | undefined + const acceptedAsyncTurnIsDetached = () => asyncRequested && sessionAccepted + const requestAbortStopsLifecycle = () => + request.signal.aborted && !acceptedAsyncTurnIsDetached() + + const requestExplicitAbortOnce = () => { + if (!lifecycleStarted || !allowExplicitAbort) return undefined + if (!explicitAbortRequest) { + explicitAbortRequest = requestExplicitStreamAbort({ + streamId: messageId, + // Go scopes the live stream to its execution/billing actor, while Sim + // must choose the upstream environment from the API-key owner. Keeping + // those identities separate prevents an actor override from rerouting + // Stop without breaking Go's owner-scoped abort marker. + userId: actorUserId, + routingUserId: authenticatedUserId, + chatId, + workspaceId, + }).catch((error) => { + logger.warn(`[${requestId}] Failed to send explicit abort for v2 chat`, { + error: toError(error).message, + }) + }) + } + return explicitAbortRequest + } + + /** + * A normal disconnect is an explicit stop request. Once an asynchronous + * caller has received its durable session receipt, however, disconnect is + * passive and the route keeps draining the Go leg into persisted state. + * In either case the route owns the chat lease until lifecycle settlement. + */ + const abortLifecycle = () => { + if (acceptedAsyncTurnIsDetached()) return + abortRequested = true + requestExplicitAbortOnce() + if (allowExplicitAbort && !userStopController.signal.aborted) { + userStopController.abort(AbortReason.UserStop) + } + } + const onRequestAbort = () => abortLifecycle() + + if (request.signal.aborted) onRequestAbort() + else request.signal.addEventListener('abort', onRequestAbort, { once: true }) + + let heartbeatId: ReturnType | undefined + const stream = new ReadableStream({ + start(controller) { + publicStreamOpen = true + registerActiveStream(messageId, lifecycleAbortController, userStopController) + const abortPoller = startAbortPoller(messageId, lifecycleAbortController, { + requestId, + chatId, + userStopController, + }) + const send = (data: unknown): boolean => { + if (cancelled || !publicStreamOpen) return false + controller.enqueue(encodeSSEEnvelope(data)) + return true + } + const activityProjector = new ChatActivityProjector() + const sendActivities = (activities: V2ChatActivity[]) => { + for (const activity of activities) send({ type: 'activity', data: activity }) + } + + let pendingTitle = syncedChat?.chat?.title?.trim() || undefined + let publishedTitle: string | undefined + let replayFinalized = false + let runSegmentPromise: Promise | undefined + const publishTitle = (title: string) => { + const next = title.trim() + if (!next) return + pendingTitle = next + if (!sessionAccepted || next === publishedTitle) return + if ( + send({ + type: 'session', + chatId, + ...(asyncRequested && runId ? { runId } : {}), + title: next, + }) + ) { + publishedTitle = next + } + } + const sendSession = () => { + if (sessionAccepted) return + const sent = send({ + type: 'session', + continuationToken: refreshedContinuationToken, + requestId, + ...(syncedChat ? { chatId } : {}), + ...(asyncRequested && runId ? { runId } : {}), + ...(pendingTitle ? { title: pendingTitle } : {}), + }) + if (!sent) return + sessionAccepted = true + if (pendingTitle) publishedTitle = pendingTitle + } + heartbeatId = setInterval(() => { + if (!cancelled && publicStreamOpen) { + controller.enqueue(encodeSSEComment(`heartbeat ${new Date().toISOString()}`)) + } + }, HEARTBEAT_INTERVAL_MS) + + void (async () => { + try { + if (lifecycleAbortController.signal.aborted || userStopController.signal.aborted) { + return + } + + if (replayPublisher && syncedChat && executionId && runId) { + await Promise.all([resetBuffer(messageId), clearFilePreviewSessions(messageId)]) + const createRunSegmentPromise = createRunSegment({ + id: runId, + executionId, + chatId, + userId: authenticatedUserId, + workspaceId, + streamId: messageId, + model: null, + requestContext: { requestId, source: 'v2_chat' }, + }) + runSegmentPromise = asyncRequested + ? createRunSegmentPromise + : createRunSegmentPromise.catch((error) => { + logger.warn(`[${requestId}] Failed to create v2 chat run segment`, { + error: getErrorMessage(error), + }) + }) + if (asyncRequested) await runSegmentPromise + replayPublisher.publish({ + type: MothershipStreamV1EventType.session, + payload: { kind: MothershipStreamV1SessionKind.chat, chatId }, + }) + await replayPublisher.flush() + await persistCopilotUserMessage({ + chatId, + userMessageId: messageId, + message: effectivePrompt, + contexts, + workspaceId, + notifyWorkspaceStatus: true, + }) + fireTitleGeneration({ + chatId, + currentChat: syncedChat.chat, + isNewChat: syncedChat.isNewChat, + userId: authenticatedUserId, + message: effectivePrompt, + titleModel: V2_CHAT_TITLE_MODEL, + workspaceId, + billingAttribution, + requestId, + resolvedSecretTraceRegistry: V2_CHAT_TITLE_SECRET_REGISTRY, + publisher: { + publish(event) { + replayPublisher.publish(event) + if ( + event.type === MothershipStreamV1EventType.session && + event.payload.kind === MothershipStreamV1SessionKind.title + ) { + publishTitle(event.payload.title) + } + }, + }, + }) + } + + lifecycleStarted = true + if (abortRequested) requestExplicitAbortOnce() + const result = await runWorkspaceChat({ + prompt: effectivePrompt, + authorizationUserId: authenticatedUserId, + actorUserId, + workspaceId, + chatId, + messageId, + requestId, + executionId, + runId, + billingAttribution, + readOnly, + sharedWorkspaceCredential: credentialType === 'workspace', + fileAttachments: preparedAttachments.attachments, + contexts, + mcpServerIds: syncedChat?.mcpServerIds, + abortSignal: lifecycleAbortController.signal, + userStopSignal: userStopController.signal, + onInitialStreamAccepted: sendSession, + onEvent: async (event) => { + replayPublisher?.publish(event) + sendActivities(activityProjector.project(event)) + if ( + event.type === MothershipStreamV1EventType.text && + event.payload.channel === MothershipStreamV1TextChannel.assistant && + !event.scope && + event.payload.text + ) { + const text = event.payload.text + if (!publicChatUsageLimitMessage(text)) { + send({ type: 'text', delta: text }) + } + } + }, + onComplete: onTurnComplete, + onError: onTurnError, + }) + + if (replayPublisher && runId) { + await runSegmentPromise + const replayOutcome = result.success + ? RequestTraceV1Outcome.success + : result.cancelled || + lifecycleAbortController.signal.aborted || + userStopController.signal.aborted || + requestAbortStopsLifecycle() + ? RequestTraceV1Outcome.cancelled + : RequestTraceV1Outcome.error + await finalizeStream(result, replayPublisher, runId, replayOutcome, requestId) + replayFinalized = true + } + + const upstreamUsageLimit = publicChatUsageLimitMessage(result.content) + if (upstreamUsageLimit) { + allowExplicitAbort = false + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { + code: 'USAGE_LIMIT_EXCEEDED', + message: upstreamUsageLimit, + }, + }) + return + } + + if (!sessionAccepted) { + throw new Error('Mothership did not acknowledge the initial chat stream') + } + if ( + lifecycleAbortController.signal.aborted || + userStopController.signal.aborted || + requestAbortStopsLifecycle() || + result.cancelled + ) { + requestExplicitAbortOnce() + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { code: 'CLIENT_CLOSED_REQUEST', message: 'Chat request cancelled' }, + }) + return + } + + if (!result.success) { + requestExplicitAbortOnce() + logger.error(`[${requestId}] V2 chat failed`, { + workspaceId, + error: result.error, + errors: result.errors, + }) + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { + code: 'INTERNAL_ERROR', + message: 'Chat request failed', + }, + }) + return + } + + allowExplicitAbort = false + + sendActivities(activityProjector.finish('complete')) + send({ + type: 'complete', + data: toPublicChatResult(result, refreshedContinuationToken), + }) + if (!cancelled) controller.enqueue(encoder.encode('data: [DONE]\n\n')) + publicStreamOpen = false + } catch (error) { + const aborted = + lifecycleAbortController.signal.aborted || + userStopController.signal.aborted || + requestAbortStopsLifecycle() || + isAbortError(error) + const terminalResult: OrchestratorResult = { + success: false, + cancelled: aborted, + content: '', + contentBlocks: [], + toolCalls: [], + error: toError(error).message, + } + if (!replayFinalized) { + if (aborted) { + await onTurnComplete?.(terminalResult) + } else { + await onTurnError?.(toError(error), terminalResult) + } + if (replayPublisher && runId) { + try { + await runSegmentPromise + await finalizeStream( + terminalResult, + replayPublisher, + runId, + aborted ? RequestTraceV1Outcome.cancelled : RequestTraceV1Outcome.error, + requestId + ) + replayFinalized = true + } catch (finalizeError) { + logger.warn(`[${requestId}] Failed to finalize v2 replay stream`, { + error: getErrorMessage(finalizeError), + }) + } + } + } + if (!aborted) { + logger.error(`[${requestId}] V2 chat error`, { + workspaceId, + error: getErrorMessage(error, 'Unknown error'), + }) + } + requestExplicitAbortOnce() + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { + code: aborted ? 'CLIENT_CLOSED_REQUEST' : 'INTERNAL_ERROR', + message: aborted ? 'Chat request cancelled' : 'Chat request failed', + }, + }) + } finally { + publicStreamOpen = false + allowExplicitAbort = false + if (heartbeatId) clearInterval(heartbeatId) + request.signal.removeEventListener('abort', onRequestAbort) + await explicitAbortRequest + clearInterval(abortPoller) + unregisterActiveStream(messageId) + await releasePendingChatStream(chatId, messageId) + await cleanupAbortMarker(messageId) + if (replayPublisher) { + try { + await replayPublisher.close() + } catch (error) { + logger.warn(`[${requestId}] Failed to flush v2 replay stream`, { + error: getErrorMessage(error), + }) + } + await scheduleBufferCleanup(messageId) + await scheduleFilePreviewSessionCleanup(messageId) + } + if (!cancelled) controller.close() + } + })() + }, + cancel(reason) { + cancelled = true + publicStreamOpen = false + if (heartbeatId) clearInterval(heartbeatId) + abortLifecycle() + }, + }) + streamOwnsLock = true + + return new Response(stream, { + headers: { + ...SSE_RESPONSE_HEADERS, + 'Cache-Control': 'private, no-store, no-transform', + ...rateLimitHeaders(rateLimit), + }, + }) + } catch (error) { + if (!streamOwnsLock && acquiredChatId && acquiredStreamId) { + await releasePendingChatStream(acquiredChatId, acquiredStreamId) + } + logger.error(`[${requestId}] Failed to start v2 chat`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/chat/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/chat/runs/[runId]/route.test.ts new file mode 100644 index 00000000000..c06c1512ad3 --- /dev/null +++ b/apps/sim/app/api/v2/chat/runs/[runId]/route.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + readRun: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/lib/copilot/chat/application/runs', () => ({ + readChatRun: { + operation: { id: 'chat.runs.read' }, + execute: mocks.readRun, + }, +})) + +import { ChatRunProgressUnavailableError } from '@/lib/copilot/chat/application/errors' +import { InsufficientWorkspacePermissionsError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET } from '@/app/api/v2/chat/runs/[runId]/route' + +const RUN_ID = '4bfa6f89-b746-43be-8246-bf1c69b58593' +const CHAT_ID = '80a47295-040e-46f9-9ea8-ad78eff3bcab' +const auth = { + principal: { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const run = { + runId: RUN_ID, + chatId: CHAT_ID, + chatTitle: 'Release plan', + streamId: 'stream-1', + status: 'active' as const, + startedAt: new Date('2026-08-08T12:00:00.000Z'), + completedAt: null, +} +const context = () => ({ params: Promise.resolve({ runId: RUN_ID }) }) + +function callDetail() { + return GET( + new NextRequest(`http://localhost:3000/api/v2/chat/runs/${RUN_ID}?workspaceId=workspace-1`), + context() + ) +} + +describe('GET /api/v2/chat/runs/[runId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-08T13:00:00.000Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-08T13:00:00.000Z'), + }) + mocks.readRun.mockResolvedValue({ + run, + status: 'active', + completedAt: null, + response: 'Working', + activities: [{ kind: 'tool', id: 'tool-1', label: 'Reading file', state: 'running' }], + }) + }) + + it('projects the authorized application result through the public contract', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/chat/runs/${RUN_ID}?workspaceId=workspace-1` + ) + const response = await GET(request, context()) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + runId: RUN_ID, + chatId: CHAT_ID, + chatTitle: 'Release plan', + status: 'active', + startedAt: '2026-08-08T12:00:00.000Z', + completedAt: null, + response: 'Working', + activities: [{ kind: 'tool', id: 'tool-1', label: 'Reading file', state: 'running' }], + }, + }) + expect(mocks.readRun).toHaveBeenCalledWith({ + principal: auth.principal, + input: { runId: RUN_ID, workspaceId: 'workspace-1' }, + request, + }) + }) + + it.each([ + new InsufficientWorkspacePermissionsError(), + new OrchestrationError('not_found', 'Workspace not found'), + new OrchestrationError('not_found', 'Chat run not found'), + ])('uniformly conceals inaccessible scoped runs', async (error) => { + mocks.readRun.mockRejectedValue(error) + + const response = await callDetail() + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat run not found' }, + }) + }) + + it('returns a retryable 503 for temporarily unavailable progress', async () => { + mocks.readRun.mockRejectedValue(new ChatRunProgressUnavailableError()) + + const response = await callDetail() + + expect(response.status).toBe(503) + expect((await response.json()).error.code).toBe('SERVICE_UNAVAILABLE') + }) + + it('does not disguise unexpected infrastructure failures as absence', async () => { + mocks.readRun.mockRejectedValue(new Error('database unavailable')) + + const response = await callDetail() + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/chat/runs/[runId]/route.ts b/apps/sim/app/api/v2/chat/runs/[runId]/route.ts new file mode 100644 index 00000000000..864fd284298 --- /dev/null +++ b/apps/sim/app/api/v2/chat/runs/[runId]/route.ts @@ -0,0 +1,32 @@ +import { v2GetChatRunContract } from '@/lib/api/contracts/v2/chat-runs' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { toPublicChatRunSummary } from '@/lib/copilot/chat/api/run-presenters' +import { v2ChatRunErrorPolicies } from '@/lib/copilot/chat/api/run-route-policy' +import { chatOperations } from '@/lib/copilot/chat/application/operations' +import { readChatRun } from '@/lib/copilot/chat/application/runs' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/chat/runs/[runId] — safe pollable run status and progress. */ +export const GET = defineV2JsonRoute({ + contract: v2GetChatRunContract, + auth: v2ApiKeyAuth, + operation: chatOperations.readRun, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ChatRunErrorPolicies.detail, + mapInput: ({ params, query }) => ({ + runId: params.runId, + workspaceId: query.workspaceId, + }), + useCase: readChatRun, + present: ({ run, status, completedAt, response, activities }) => ({ + data: { + ...toPublicChatRunSummary(run), + status, + completedAt: completedAt?.toISOString() ?? null, + response, + activities, + }, + }), +}) diff --git a/apps/sim/app/api/v2/chat/runs/route.test.ts b/apps/sim/app/api/v2/chat/runs/route.test.ts new file mode 100644 index 00000000000..5682ba00ba1 --- /dev/null +++ b/apps/sim/app/api/v2/chat/runs/route.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + listRuns: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/lib/copilot/chat/application/runs', () => ({ + listChatRuns: { + operation: { id: 'chat.runs.list' }, + execute: mocks.listRuns, + }, +})) + +import { PrincipalKindAuthorizationError } from '@/lib/core/application' +import { GET } from '@/app/api/v2/chat/runs/route' + +const RUN_ID = '4bfa6f89-b746-43be-8246-bf1c69b58593' +const CHAT_ID = '80a47295-040e-46f9-9ea8-ad78eff3bcab' +const auth = { + principal: { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const run = { + runId: RUN_ID, + chatId: CHAT_ID, + chatTitle: 'Release plan', + streamId: 'stream-1', + status: 'complete' as const, + startedAt: new Date('2026-08-08T12:00:00.000Z'), + completedAt: new Date('2026-08-08T12:01:00.000Z'), +} + +function callList(query = 'workspaceId=workspace-1') { + return GET(new NextRequest(`http://localhost:3000/api/v2/chat/runs?${query}`)) +} + +describe('GET /api/v2/chat/runs', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-08T13:00:00.000Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-08T13:00:00.000Z'), + }) + mocks.listRuns.mockResolvedValue({ rows: [], hasMore: false }) + }) + + it('routes validated list input through the semantic application operation', async () => { + mocks.listRuns.mockResolvedValue({ rows: [run], hasMore: true }) + const request = new NextRequest( + 'http://localhost:3000/api/v2/chat/runs?workspaceId=workspace-1&status=complete&limit=1' + ) + + const response = await GET(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual([ + { + runId: RUN_ID, + chatId: CHAT_ID, + chatTitle: 'Release plan', + status: 'complete', + startedAt: '2026-08-08T12:00:00.000Z', + completedAt: '2026-08-08T12:01:00.000Z', + }, + ]) + expect(body.nextCursor).toEqual(expect.any(String)) + expect(mocks.listRuns).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: 'workspace-1', + status: 'complete', + limit: 1, + cursorKeys: undefined, + }, + request, + }) + expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + }) + + it('rejects malformed cursors before application execution', async () => { + const response = await callList('workspaceId=workspace-1&cursor=not-a-cursor') + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toMatch(/cursor does not match/i) + expect(mocks.listRuns).not.toHaveBeenCalled() + }) + + it('renders the personal-key-only operation failure consistently', async () => { + mocks.listRuns.mockRejectedValue( + new PrincipalKindAuthorizationError('workspace_api_key', 'chat.runs.list') + ) + + const response = await callList() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Chat runs require a personal API key' }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/chat/runs/route.ts b/apps/sim/app/api/v2/chat/runs/route.ts new file mode 100644 index 00000000000..d58e894b44f --- /dev/null +++ b/apps/sim/app/api/v2/chat/runs/route.ts @@ -0,0 +1,47 @@ +import { v2ListChatRunsContract } from '@/lib/api/contracts/v2/chat-runs' +import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { toPublicChatRunSummary } from '@/lib/copilot/chat/api/run-presenters' +import { v2ChatRunErrorPolicies } from '@/lib/copilot/chat/api/run-route-policy' +import { chatOperations } from '@/lib/copilot/chat/application/operations' +import { listChatRuns } from '@/lib/copilot/chat/application/runs' +import { encodePublicChatRunCursor, PUBLIC_CHAT_RUN_SORT } from '@/lib/copilot/chat/public-runs' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { decodeSortedCursor, encodeSortedCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +function decodeCursor(cursor: string | undefined) { + const decoded = decodeSortedCursor(cursor, PUBLIC_CHAT_RUN_SORT) + if (decoded.status === 'invalid') { + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + } + return decoded.status === 'ok' ? decoded.keys : undefined +} + +/** GET /api/v2/chat/runs — list owned root Mothership chat runs. */ +export const GET = defineV2JsonRoute({ + contract: v2ListChatRunsContract, + auth: v2ApiKeyAuth, + operation: chatOperations.listRuns, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ChatRunErrorPolicies.default, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + status: query.status, + limit: query.limit, + cursorKeys: decodeCursor(query.cursor), + }), + useCase: listChatRuns, + present: ({ rows, hasMore }) => { + const last = rows.at(-1) + return { + data: rows.map(toPublicChatRunSummary), + nextCursor: + hasMore && last + ? encodeSortedCursor(PUBLIC_CHAT_RUN_SORT, encodePublicChatRunCursor(last)) + : null, + } + }, +}) diff --git a/apps/sim/app/api/v2/chats/[chatId]/route.test.ts b/apps/sim/app/api/v2/chats/[chatId]/route.test.ts new file mode 100644 index 00000000000..997739fa9a0 --- /dev/null +++ b/apps/sim/app/api/v2/chats/[chatId]/route.test.ts @@ -0,0 +1,372 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, flattenMockConditions, resetDbChainMock, schemaMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockEnvFlags, + mockGetAccessibleCopilotChatWithMessages, + mockIssueV2ChatContinuationToken, + mockPublishStatusChanged, + mockCaptureServerEvent, + mockReconcileChatStreamMarkers, + mockResolveWorkspaceAccess, + mockV2ApiGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockEnvFlags: { isAuthDisabled: false }, + mockGetAccessibleCopilotChatWithMessages: vi.fn(), + mockIssueV2ChatContinuationToken: vi.fn(), + mockPublishStatusChanged: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockReconcileChatStreamMarkers: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockV2ApiGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockV2ApiGateError, +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatWithMessages: mockGetAccessibleCopilotChatWithMessages, +})) + +vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ + reconcileChatStreamMarkers: mockReconcileChatStreamMarkers, +})) + +vi.mock('@/lib/copilot/headless/continuation-token', () => ({ + issueV2ChatContinuationToken: mockIssueV2ChatContinuationToken, +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: mockPublishStatusChanged }, +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: mockCaptureServerEvent, +})) + +vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) + +import { GET, PATCH } from '@/app/api/v2/chats/[chatId]/route' + +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'personal' as const, + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-07T13:00:00.000Z'), +} + +function buildChat(overrides: Record = {}) { + return { + id: 'chat-1', + userId: 'user-1', + workflowId: null, + workspaceId: 'workspace-1', + type: 'mothership', + title: 'Release plan', + conversationId: 'stream-stale', + resources: null, + createdAt: new Date('2026-08-07T11:00:00.000Z'), + updatedAt: new Date('2026-08-07T12:00:00.000Z'), + messages: [], + ...overrides, + } +} + +function callDetail(query = 'workspaceId=workspace-1') { + return GET(new NextRequest(`http://localhost:3000/api/v2/chats/chat-1?${query}`), { + params: Promise.resolve({ chatId: 'chat-1' }), + }) +} + +function callRename(body: Record) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/chats/chat-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ chatId: 'chat-1' }) } + ) +} + +describe('GET /api/v2/chats/[chatId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnvFlags.isAuthDisabled = false + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetAccessibleCopilotChatWithMessages.mockResolvedValue(buildChat()) + mockReconcileChatStreamMarkers.mockResolvedValue( + new Map([['chat-1', { chatId: 'chat-1', streamId: null, status: 'inactive' }]]) + ) + mockIssueV2ChatContinuationToken.mockResolvedValue('continuation-token') + }) + + it('rejects workspace keys before loading private chat history', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) + + const response = await callDetail() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Chat history requires a personal API key', + }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockGetAccessibleCopilotChatWithMessages).not.toHaveBeenCalled() + }) + + it('returns the workspace-access failure without loading the chat', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callDetail() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Access denied' }, + }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + RATE_LIMIT, + 'user-1', + 'workspace-1', + 'read' + ) + expect(mockGetAccessibleCopilotChatWithMessages).not.toHaveBeenCalled() + }) + + it('treats the auth-disabled principal like a session principal for workspace access', async () => { + mockEnvFlags.isAuthDisabled = true + + const response = await callDetail() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ keyType: undefined }), + 'user-1', + 'workspace-1', + 'read' + ) + }) + + it.each([ + ['an inaccessible chat', null], + ['a workflow-scoped chat', buildChat({ type: 'copilot' })], + ['a chat from another workspace', buildChat({ workspaceId: 'workspace-2' })], + ])('masks %s as the same not-found response', async (_case, chat) => { + mockGetAccessibleCopilotChatWithMessages.mockResolvedValue(chat) + + const response = await callDetail() + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat not found' }, + }) + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockReconcileChatStreamMarkers).not.toHaveBeenCalled() + }) + + it('projects display-safe messages and reports the reconciled active marker', async () => { + mockGetAccessibleCopilotChatWithMessages.mockResolvedValue( + buildChat({ + messages: [ + { + id: 'message-user', + role: 'user', + content: 'Ship it', + timestamp: '2026-08-07T11:30:00.000Z', + contexts: [{ kind: 'workflow', label: 'Release', workflowId: 'workflow-1' }], + }, + { + id: 'message-assistant', + role: 'assistant', + content: 'Done', + timestamp: '2026-08-07T11:31:00.000Z', + requestId: 'request-private', + contentBlocks: [{ type: 'text', content: 'Done' }], + }, + { + id: 'message-system', + role: 'system', + content: 'private instructions', + timestamp: '2026-08-07T11:29:00.000Z', + }, + null, + ], + }) + ) + mockReconcileChatStreamMarkers.mockResolvedValueOnce( + new Map([['chat-1', { chatId: 'chat-1', streamId: 'stream-live', status: 'active' }]]) + ) + + const response = await callDetail('workspaceId=workspace-1&readOnly=true') + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual({ + id: 'chat-1', + title: 'Release plan', + active: true, + continuationToken: 'continuation-token', + messages: [ + { + id: 'message-user', + role: 'user', + content: 'Ship it', + timestamp: '2026-08-07T11:30:00.000Z', + }, + { + id: 'message-assistant', + role: 'assistant', + content: 'Done', + timestamp: '2026-08-07T11:31:00.000Z', + }, + ], + }) + expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith( + [{ chatId: 'chat-1', streamId: 'stream-stale' }], + { repairVerifiedStaleMarkers: true } + ) + }) + + it.each([ + ['true', true], + ['false', false], + ])('binds readOnly=%s into the minted continuation token', async (raw, expected) => { + const response = await callDetail(`workspaceId=workspace-1&readOnly=${raw}`) + + expect(response.status).toBe(200) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith({ + chatId: 'chat-1', + workspaceId: 'workspace-1', + authorizationUserId: 'user-1', + credentialType: 'personal', + readOnly: expected, + persistence: 'sim', + }) + }) +}) + +describe('PATCH /api/v2/chats/[chatId]', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockEnvFlags.isAuthDisabled = false + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + }) + + it('renames an owned chat and notifies the synchronized Home list', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'chat-1', workspaceId: 'workspace-1' }]) + + const response = await callRename({ + workspaceId: 'workspace-1', + title: 'Incident investigation', + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { id: 'chat-1', title: 'Incident investigation' }, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + title: 'Incident investigation', + updatedAt: expect.any(Date), + lastSeenAt: expect.any(Date), + }) + const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(conditions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.id, + right: 'chat-1', + }), + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.userId, + right: 'user-1', + }), + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.workspaceId, + right: 'workspace-1', + }), + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.type, + right: 'mothership', + }), + expect.objectContaining({ + type: 'isNull', + column: schemaMock.copilotChats.deletedAt, + }), + ]) + ) + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + type: 'renamed', + }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'task_renamed', + { workspace_id: 'workspace-1' }, + { groups: { workspace: 'workspace-1' } } + ) + }) + + it('rejects workspace keys before touching private chat data', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) + + const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) + + expect(response.status).toBe(403) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('returns the workspace-access failure before updating the chat', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) + + expect(response.status).toBe(403) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('masks missing, deleted, foreign, and non-mothership chats as not found', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat not found' }, + }) + expect(mockPublishStatusChanged).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/chats/[chatId]/route.ts b/apps/sim/app/api/v2/chats/[chatId]/route.ts new file mode 100644 index 00000000000..16b09ac2bf8 --- /dev/null +++ b/apps/sim/app/api/v2/chats/[chatId]/route.ts @@ -0,0 +1,158 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { v2GetChatContract, v2RenameChatContract } from '@/lib/api/contracts/v2/chats' +import { parseRequest } from '@/lib/api/server' +import { getAccessibleCopilotChatWithMessages } from '@/lib/copilot/chat/lifecycle' +import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' +import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' +import { chatPubSub } from '@/lib/copilot/chat-status' +import { issueV2ChatContinuationToken } from '@/lib/copilot/headless/continuation-token' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2ChatDetailAPI') +type ChatRouteContext = { params: Promise<{ chatId: string }> } + +/** GET /api/v2/chats/[chatId] — open one owned chat and mint a fresh resume token. */ +export const GET = withRouteHandler(async (request: NextRequest, context: ChatRouteContext) => { + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + if (rateLimit.keyType === 'workspace') { + return v2Error('FORBIDDEN', 'Chat history requires a personal API key') + } + + const parsed = await parseRequest(v2GetChatContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.params + const { workspaceId, readOnly } = parsed.data.query + + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const chat = await getAccessibleCopilotChatWithMessages(chatId, userId) + if (!chat || chat.type !== 'mothership' || chat.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Chat not found') + } + + const streamMarkers = await reconcileChatStreamMarkers( + [{ chatId: chat.id, streamId: chat.conversationId }], + { repairVerifiedStaleMarkers: true } + ) + const active = Boolean(streamMarkers.get(chat.id)?.streamId) + const continuationToken = await issueV2ChatContinuationToken({ + chatId: chat.id, + workspaceId, + authorizationUserId: userId, + credentialType: 'personal', + readOnly, + persistence: 'sim', + }) + const messages = (Array.isArray(chat.messages) ? chat.messages : []) + .filter((message): message is Record => Boolean(message)) + .map(normalizeMessage) + .filter((message) => message.role === 'user' || message.role === 'assistant') + .map(({ id, role, content, timestamp }) => ({ id, role, content, timestamp })) + + return v2Data( + { + id: chat.id, + title: chat.title, + messages, + continuationToken, + active, + }, + { rateLimit } + ) + } catch (error) { + logger.error('Failed to open v2 chat', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/chats/[chatId] — rename one owned workspace chat. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: ChatRouteContext) => { + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + if (rateLimit.keyType === 'workspace') { + return v2Error('FORBIDDEN', 'Renaming chats requires a personal API key') + } + + const parsed = await parseRequest(v2RenameChatContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.params + const { workspaceId, title } = parsed.data.body + + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const now = new Date() + const [updated] = await db + .update(copilotChats) + .set({ title, updatedAt: now, lastSeenAt: now }) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, userId), + eq(copilotChats.workspaceId, workspaceId), + eq(copilotChats.type, 'mothership'), + isNull(copilotChats.deletedAt) + ) + ) + .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId }) + + if (!updated) return v2Error('NOT_FOUND', 'Chat not found') + + if (updated.workspaceId) { + chatPubSub?.publishStatusChanged({ + workspaceId: updated.workspaceId, + chatId: updated.id, + type: 'renamed', + }) + captureServerEvent( + userId, + 'task_renamed', + { workspace_id: updated.workspaceId }, + { groups: { workspace: updated.workspaceId } } + ) + } + + return v2Data({ id: updated.id, title }, { rateLimit }) + } catch (error) { + logger.error('Failed to rename v2 chat', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/chats/route.test.ts b/apps/sim/app/api/v2/chats/route.test.ts new file mode 100644 index 00000000000..b59eed21878 --- /dev/null +++ b/apps/sim/app/api/v2/chats/route.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockEnvFlags, + mockReconcileChatStreamMarkers, + mockResolveWorkspaceAccess, + mockV2ApiGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockEnvFlags: { isAuthDisabled: false }, + mockReconcileChatStreamMarkers: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockV2ApiGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockV2ApiGateError, +})) + +vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ + reconcileChatStreamMarkers: mockReconcileChatStreamMarkers, +})) + +vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) + +import { GET } from '@/app/api/v2/chats/route' + +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'personal' as const, + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-07T13:00:00.000Z'), +} + +function buildChat(overrides: Record = {}) { + return { + id: 'chat-1', + title: 'Release plan', + updatedAt: new Date('2026-08-07T12:00:00.000Z'), + pinned: true, + activeStreamId: 'stream-stale', + ...overrides, + } +} + +function callList(query = 'workspaceId=workspace-1') { + return GET(new NextRequest(`http://localhost:3000/api/v2/chats?${query}`)) +} + +describe('GET /api/v2/chats', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockEnvFlags.isAuthDisabled = false + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockReconcileChatStreamMarkers.mockImplementation( + async (candidates: Array<{ chatId: string; streamId: string | null }>) => + new Map( + candidates.map((candidate) => [ + candidate.chatId, + { + chatId: candidate.chatId, + streamId: candidate.streamId, + status: candidate.streamId ? 'active' : 'inactive', + }, + ]) + ) + ) + }) + + it('rejects workspace keys before reading private chat history', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) + + const response = await callList() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Chat history requires a personal API key', + }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('returns the workspace-access failure without querying chats', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callList() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Access denied' }, + }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + RATE_LIMIT, + 'user-1', + 'workspace-1', + 'read' + ) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('treats the auth-disabled principal like a session principal for workspace access', async () => { + mockEnvFlags.isAuthDisabled = true + queueTableRows(schemaMock.copilotChats, []) + + const response = await callList() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ keyType: undefined }), + 'user-1', + 'workspace-1', + 'read' + ) + }) + + it('bounds the SQL page, maps summaries, and derives active state from the live marker', async () => { + queueTableRows(schemaMock.copilotChats, [ + buildChat(), + buildChat({ + id: 'chat-2', + title: null, + updatedAt: new Date('2026-08-06T12:00:00.000Z'), + pinned: false, + activeStreamId: 'stream-live', + }), + buildChat({ id: 'chat-3' }), + ]) + mockReconcileChatStreamMarkers.mockResolvedValueOnce( + new Map([ + ['chat-1', { chatId: 'chat-1', streamId: null, status: 'inactive' }], + ['chat-2', { chatId: 'chat-2', streamId: 'stream-live', status: 'active' }], + ]) + ) + + const response = await callList('workspaceId=workspace-1&limit=2') + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual([ + { + id: 'chat-1', + title: 'Release plan', + updatedAt: '2026-08-07T12:00:00.000Z', + pinned: true, + active: false, + }, + { + id: 'chat-2', + title: null, + updatedAt: '2026-08-06T12:00:00.000Z', + pinned: false, + active: true, + }, + ]) + expect(body.nextCursor).toEqual(expect.any(String)) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(3) + expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith( + [ + { chatId: 'chat-1', streamId: 'stream-stale' }, + { chatId: 'chat-2', streamId: 'stream-live' }, + ], + { repairVerifiedStaleMarkers: true } + ) + }) + + it('replays its opaque cursor as a keyset bound', async () => { + queueTableRows(schemaMock.copilotChats, [buildChat(), buildChat({ id: 'chat-2' })]) + const first = await callList('workspaceId=workspace-1&limit=1') + const { nextCursor } = await first.json() + + queueTableRows(schemaMock.copilotChats, [ + buildChat({ + id: 'chat-2', + title: 'Older chat', + updatedAt: new Date('2026-08-06T12:00:00.000Z'), + pinned: false, + activeStreamId: null, + }), + ]) + const second = await callList( + `workspaceId=workspace-1&limit=1&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(second.status).toBe(200) + const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(conditions.some((condition) => condition?.type === 'or')).toBe(true) + }) + + it('rejects a malformed cursor instead of restarting at the first page', async () => { + const response = await callList('workspaceId=workspace-1&cursor=not-a-cursor') + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toMatch(/cursor does not match/i) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/chats/route.ts b/apps/sim/app/api/v2/chats/route.ts new file mode 100644 index 00000000000..f713db70161 --- /dev/null +++ b/apps/sim/app/api/v2/chats/route.ts @@ -0,0 +1,139 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull, sql } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2ChatSummary, v2ListChatsContract } from '@/lib/api/contracts/v2/chats' +import { + encodeKeyset, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + timestampKey, + uuidKey, +} from '@/lib/api/list-query' +import { parseRequest } from '@/lib/api/server' +import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + decodeSortedCursor, + encodeSortedCursor, + v2CursorList, + v2CursorSortError, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2ChatsAPI') +const CHAT_SORT = 'pinned:desc,updatedAt:desc' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +type ChatRow = { + id: string + title: string | null + updatedAt: Date + pinned: boolean + activeStreamId: string | null +} + +const pinnedRank = sql`case when ${copilotChats.pinned} then 1 else 0 end` +const CHAT_KEYS = [ + numberKey(pinnedRank, (row) => (row.pinned ? 1 : 0)), + timestampKey(copilotChats.updatedAt, (row) => row.updatedAt), + uuidKey(copilotChats.id, (row) => row.id), +] + +/** GET /api/v2/chats — bounded personal chat history for the terminal picker. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + // A workspace key can be held by people other than its creator. Its + // creator's UI chats are private and must never become shared-key data. + if (rateLimit.keyType === 'workspace') { + return v2Error('FORBIDDEN', 'Chat history requires a personal API key') + } + + const parsed = await parseRequest( + v2ListChatsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, search, limit, cursor } = parsed.data.query + + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const decoded = decodeSortedCursor(cursor, CHAT_SORT) + if (decoded.status === 'invalid') return v2CursorSortError() + const resumeAfter = + decoded.status === 'ok' ? keysetAfter(CHAT_KEYS, decoded.keys, 'desc') : undefined + if (resumeAfter === null) return v2CursorSortError() + + const rows = await db + .select({ + id: copilotChats.id, + title: copilotChats.title, + updatedAt: copilotChats.updatedAt, + pinned: copilotChats.pinned, + activeStreamId: copilotChats.conversationId, + }) + .from(copilotChats) + .where( + and( + eq(copilotChats.userId, userId), + eq(copilotChats.workspaceId, workspaceId), + eq(copilotChats.type, 'mothership'), + isNull(copilotChats.deletedAt), + searchFilter(copilotChats.title, search), + resumeAfter + ) + ) + .orderBy(...listOrderBy(keysetColumns(CHAT_KEYS), 'desc')) + .limit(limit + 1) + + const page = rows.slice(0, limit) + const streamMarkers = await reconcileChatStreamMarkers( + page.map((chat) => ({ chatId: chat.id, streamId: chat.activeStreamId })), + { repairVerifiedStaleMarkers: true } + ) + const data: V2ChatSummary[] = page.map((chat) => ({ + id: chat.id, + title: chat.title, + updatedAt: chat.updatedAt.toISOString(), + pinned: chat.pinned, + active: Boolean(streamMarkers.get(chat.id)?.streamId), + })) + + const last = page.at(-1) + const nextCursor = + rows.length > limit && last + ? encodeSortedCursor(CHAT_SORT, encodeKeyset(CHAT_KEYS, last)) + : null + + return v2CursorList(data, nextCursor, { rateLimit }) + } catch (error) { + logger.error('Failed to list v2 chats', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index 989a7ef434b..714c83bf44c 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ admit: vi.fn(), + download: vi.fn(), updateContent: vi.fn(), authenticateV2ApiKey: vi.fn(), checkRateLimitDirect: vi.fn(), @@ -13,6 +14,13 @@ const mocks = vi.hoisted(() => ({ getUserEmailsByIds: vi.fn(), })) +vi.mock('@/lib/workspace-files/application/download-workspace-file', () => ({ + downloadWorkspaceFileStream: { + operation: { id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.download, + }, +})) + vi.mock('@/lib/workspace-files/orchestration', () => ({ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, })) @@ -47,8 +55,9 @@ vi.mock('@/lib/users/queries', () => ({ requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, })) +import { InsufficientWorkspacePermissionsError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { PUT } from '@/app/api/v2/files/[fileId]/content/route' +import { GET, PUT } from '@/app/api/v2/files/[fileId]/content/route' const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' @@ -76,6 +85,15 @@ const record = { uploadedAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-03T00:00:00Z'), } +const context = { params: Promise.resolve({ fileId: FILE_ID }) } + +const callGet = () => + GET( + new NextRequest( + `http://localhost:3000/api/v2/files/${FILE_ID}/content?workspaceId=${WORKSPACE_ID}` + ), + context + ) const callPut = (body: unknown, contentLength?: number) => PUT( @@ -87,9 +105,53 @@ const callPut = (body: unknown, contentLength?: number) => }, body: typeof body === 'string' ? body : JSON.stringify(body), }), - { params: Promise.resolve({ fileId: FILE_ID }) } + context ) +describe('GET /api/v2/files/[fileId]/content', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.download.mockResolvedValue({ + file: record, + stream: new Blob(['id,name\n']).stream(), + }) + }) + + it('streams bytes through the binary adapter', async () => { + const response = await callGet() + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/csv') + expect(response.headers.get('Content-Disposition')).toContain('data.csv') + expect(await response.text()).toBe('id,name\n') + expect(mocks.download).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), + }) + }) + + it('conceals content authorization failures', async () => { + mocks.download.mockRejectedValue(new InsufficientWorkspacePermissionsError()) + + const response = await callGet() + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) +}) + describe('PUT /api/v2/files/[fileId]/content', () => { beforeEach(() => { vi.clearAllMocks() @@ -110,9 +172,7 @@ describe('PUT /api/v2/files/[fileId]/content', () => { }) it('performs authenticated admission before parsing a large or malformed body', async () => { - mocks.admit.mockRejectedValue( - new OrchestrationError('forbidden', 'Insufficient workspace permissions') - ) + mocks.admit.mockRejectedValue(new InsufficientWorkspacePermissionsError()) const response = await callPut('{not-json') diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index c85c5251366..28830f40731 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -1,6 +1,12 @@ -import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' -import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2GetFileContentContract, v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' +import { + defineV2BinaryRoute, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' import { fileOperations } from '@/lib/workspace-files/application/operations' import { admitUpdateWorkspaceFileContent, @@ -13,6 +19,26 @@ import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** GET /api/v2/files/[fileId]/content — Stream a file's bytes. */ +export const GET = defineV2BinaryRoute({ + contract: v2GetFileContentContract, + auth: v2ApiKeyAuth, + operation: fileOperations.download, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: downloadWorkspaceFileStream, + present: ({ file, stream }) => ({ + body: stream, + contentType: file.type || 'application/octet-stream', + contentDisposition: `attachment; filename="${file.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(file.name)}`, + contentLength: file.size, + }), +}) + /** PUT /api/v2/files/[fileId]/content — Replace a file's bytes. */ export const PUT = defineV2JsonRoute({ contract: v2UpdateFileContentContract, diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts deleted file mode 100644 index 1cd31213dcf..00000000000 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - readMetadata: vi.fn(), - authenticateV2ApiKey: vi.fn(), - checkRateLimitDirect: vi.fn(), - checkRateLimitDirectOrThrow: vi.fn(), - getUserEmailsByIds: vi.fn(), -})) - -vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ - readWorkspaceFileMetadata: { - operation: { id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow' }, - execute: mocks.readMetadata, - }, -})) - -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticateV2ApiKey, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -vi.mock('@/lib/core/rate-limiter', () => ({ - getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), - RateLimiter: class RateLimiter { - checkRateLimitDirect = mocks.checkRateLimitDirect - checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mocks.getUserEmailsByIds, - requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, -})) - -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { GET } from '@/app/api/v2/files/[fileId]/metadata/route' - -const WORKSPACE_ID = 'workspace-1' -const FILE_ID = 'wf_1' -const context = { params: Promise.resolve({ fileId: FILE_ID }) } -const auth = { - principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, - rolloutUserId: 'billing-owner-1', - rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, - rateLimitSubscription: null, - keyType: 'workspace' as const, -} -const SHARE = { - id: 'share-1', - token: 'share-token', - url: 'https://example.com/f/share-token', - isActive: true, - resourceType: 'file', - resourceId: FILE_ID, - authType: 'public', - hasPassword: false, - allowedEmails: [], -} - -function buildRecord() { - return { - id: FILE_ID, - workspaceId: WORKSPACE_ID, - name: 'data.csv', - key: 'workspace/ws/1-x-data.csv', - path: '/api/files/serve/x', - size: 1024, - type: 'text/csv', - uploadedBy: 'user-1', - folderId: null, - folderPath: null, - uploadedAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - } -} - -const callGet = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/metadata?${query}`), context) - -describe('GET /api/v2/files/[fileId]/metadata', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.authenticateV2ApiKey.mockResolvedValue(auth) - mocks.checkRateLimitDirect.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2024-01-01T01:00:00Z'), - }) - mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), - }) - mocks.readMetadata.mockResolvedValue({ file: buildRecord(), share: SHARE }) - mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - }) - - it('authenticates and charges before rejecting a missing workspaceId', async () => { - const response = await callGet('') - - expect(response.status).toBe(400) - expect(mocks.authenticateV2ApiKey).toHaveBeenCalled() - expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) - expect(mocks.readMetadata).not.toHaveBeenCalled() - }) - - it('conceals an authorization failure as not found', async () => { - mocks.readMetadata.mockRejectedValue( - new OrchestrationError('forbidden', 'Insufficient workspace permissions') - ) - - const response = await callGet(`workspaceId=${WORKSPACE_ID}`) - - expect(response.status).toBe(404) - expect((await response.json()).error.code).toBe('NOT_FOUND') - }) - - it('returns the v2 metadata projection through the shared use case', async () => { - const response = await callGet(`workspaceId=${WORKSPACE_ID}`) - - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - data: { - id: FILE_ID, - name: 'data.csv', - size: 1024, - type: 'text/csv', - key: 'workspace/ws/1-x-data.csv', - folderPath: '/', - uploadedByEmail: 'ada@example.com', - uploadedAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - share: SHARE, - }, - }) - expect(mocks.readMetadata).toHaveBeenCalledWith({ - principal: auth.principal, - input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, - request: expect.anything(), - }) - }) - - it('returns a null share when the file has no share configuration', async () => { - mocks.readMetadata.mockResolvedValueOnce({ file: buildRecord(), share: null }) - - const response = await callGet(`workspaceId=${WORKSPACE_ID}`) - - expect(response.status).toBe(200) - expect((await response.json()).data.share).toBeNull() - }) -}) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts deleted file mode 100644 index 68d1420c541..00000000000 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { v2GetFileContract } from '@/lib/api/contracts/v2/files' -import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' -import { v2FileErrorPolicies } from '@/lib/workspace-files/api' -import { fileOperations } from '@/lib/workspace-files/application/operations' -import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' -import { toV2File } from '@/app/api/v2/files/utils' - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -/** GET /api/v2/files/[fileId]/metadata — Return file metadata without downloading its bytes. */ -export const GET = defineV2JsonRoute({ - contract: v2GetFileContract, - auth: v2ApiKeyAuth, - operation: fileOperations.readMetadata, - rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, - mapInput: ({ params, query }) => ({ - fileId: params.fileId, - assertedWorkspaceId: query.workspaceId, - }), - useCase: readWorkspaceFileMetadata, - present: async ({ file, share }) => ({ data: { ...(await toV2File(file)), share } }), -}) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/route.test.ts index 4168341cf70..a597957ad68 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -5,7 +5,7 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - download: vi.fn(), + describeFile: vi.fn(), rename: vi.fn(), deleteFile: vi.fn(), authenticateV2ApiKey: vi.fn(), @@ -14,10 +14,10 @@ const mocks = vi.hoisted(() => ({ getUserEmailsByIds: vi.fn(), })) -vi.mock('@/lib/workspace-files/application/download-workspace-file', () => ({ - downloadWorkspaceFileStream: { - operation: { id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow' }, - execute: mocks.download, +vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ + readWorkspaceFileMetadata: { + operation: { id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.describeFile, }, })) @@ -55,6 +55,7 @@ vi.mock('@/lib/users/queries', () => ({ requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, })) +import { InsufficientWorkspacePermissionsError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { DELETE, GET, PATCH } from '@/app/api/v2/files/[fileId]/route' @@ -90,6 +91,18 @@ function fileRecord(overrides: Record = {}) { } } +const SHARE = { + id: 'shr_1', + token: 'existing-token-abcd', + url: 'https://www.sim.ai/f/existing-token-abcd', + isActive: true, + resourceType: 'file' as const, + resourceId: FILE_ID, + authType: 'email' as const, + hasPassword: false, + allowedEmails: ['ada@example.com'], +} + describe('v2 single-file routes', () => { beforeEach(() => { vi.clearAllMocks() @@ -104,10 +117,7 @@ describe('v2 single-file routes', () => { remaining: 99, resetAt: new Date('2024-01-01T01:00:00Z'), }) - mocks.download.mockResolvedValue({ - file: fileRecord(), - stream: new Blob(['id,name\n']).stream(), - }) + mocks.describeFile.mockResolvedValue({ file: fileRecord(), share: SHARE }) mocks.rename.mockResolvedValue({ file: fileRecord({ name: 'renamed.csv' }) }) mocks.deleteFile.mockResolvedValue({ id: FILE_ID, @@ -117,29 +127,54 @@ describe('v2 single-file routes', () => { mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) - it('downloads bytes through the binary adapter with operation rate headers', async () => { + it('describes the file and its sharing state', async () => { const response = await GET( new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`), context ) expect(response.status).toBe(200) - expect(response.headers.get('Content-Type')).toBe('text/csv') - expect(response.headers.get('Content-Disposition')).toContain('data.csv') - expect(response.headers.get('X-RateLimit-Remaining')).toBe('99') - expect(await response.text()).toBe('id,name\n') - expect(mocks.download).toHaveBeenCalledWith({ + expect(await response.json()).toEqual({ + data: { + id: FILE_ID, + name: 'data.csv', + size: 8, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderPath: '/', + uploadedByEmail: 'ada@example.com', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-03T00:00:00.000Z', + sharing: { + enabled: true, + url: SHARE.url, + authType: 'email', + hasPassword: false, + allowedEmails: ['ada@example.com'], + }, + }, + }) + expect(mocks.describeFile).toHaveBeenCalledWith({ principal: auth.principal, input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, request: expect.anything(), }) }) - it('conceals download authorization failures', async () => { - mocks.download.mockRejectedValue( - new OrchestrationError('forbidden', 'Insufficient workspace permissions') + it('returns an explicit disabled sharing state', async () => { + mocks.describeFile.mockResolvedValueOnce({ file: fileRecord(), share: null }) + + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`), + context ) + expect((await response.json()).data.sharing).toEqual({ enabled: false }) + }) + + it('conceals description authorization failures', async () => { + mocks.describeFile.mockRejectedValue(new InsufficientWorkspacePermissionsError()) + const response = await GET( new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`), context @@ -178,9 +213,7 @@ describe('v2 single-file routes', () => { ) expect(conflict.status).toBe(409) - mocks.rename.mockRejectedValueOnce( - new OrchestrationError('forbidden', 'Insufficient workspace permissions') - ) + mocks.rename.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) const concealed = await PATCH( new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { method: 'PATCH', diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 5db1ff967de..50c8a0429e1 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -1,47 +1,35 @@ import { v2DeleteFileContract, - v2DownloadFileContract, + v2DescribeFileContract, v2RenameFileContract, } from '@/lib/api/contracts/v2/files' -import { - defineV2BinaryRoute, - defineV2JsonRoute, - v2ApiKeyAuth, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' -import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' -import { toV2File } from '@/app/api/v2/files/utils' +import { toV2File, toV2FileSharing } from '@/app/api/v2/files/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 /** - * GET /api/v2/files/[fileId] — Download file content (binary). - * - * The response carries no JSON envelope, so rate-limit state is surfaced via - * `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body. - * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. + * GET /api/v2/files/[fileId] — Describe one file and its sharing state. */ -export const GET = defineV2BinaryRoute({ - contract: v2DownloadFileContract, +export const GET = defineV2JsonRoute({ + contract: v2DescribeFileContract, auth: v2ApiKeyAuth, - operation: fileOperations.download, + operation: fileOperations.readMetadata, rateLimit: v2RateLimits.publicApi, errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, mapInput: ({ params, query }) => ({ fileId: params.fileId, assertedWorkspaceId: query.workspaceId, }), - useCase: downloadWorkspaceFileStream, - present: ({ file, stream }) => ({ - body: stream, - contentType: file.type || 'application/octet-stream', - contentDisposition: `attachment; filename="${file.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(file.name)}`, - contentLength: file.size, + useCase: readWorkspaceFileMetadata, + present: async ({ file, share }) => ({ + data: { ...(await toV2File(file)), sharing: toV2FileSharing(share) }, }), }) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts index 78123770c87..e5475beb7c2 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts @@ -18,8 +18,8 @@ const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { preauthRate: vi.fn(), operationRate: vi.fn(), gate: vi.fn(), - getShare: vi.fn(), updateShare: vi.fn(), + unshare: vi.fn(), }, MockV2ApiKeyUnauthenticatedError, } @@ -55,18 +55,19 @@ vi.mock('@/lib/core/utils/request', () => ({ vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) vi.mock('@/lib/workspace-files/application/share-workspace-file', () => ({ - getWorkspaceFileShare: { - operation: { id: 'files.share.read', minimumRole: 'read', workspaceApiKey: 'allow' }, - execute: mocks.getShare, - }, updateWorkspaceFileShare: { operation: { id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'allow' }, execute: mocks.updateShare, }, + unshareWorkspaceFile: { + operation: { id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.unshare, + }, })) +import { InsufficientWorkspacePermissionsError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { GET, PUT } from '@/app/api/v2/files/[fileId]/share/route' +import { DELETE, PUT } from '@/app/api/v2/files/[fileId]/share/route' const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' @@ -98,15 +99,6 @@ const SHARE = { } const context = { params: Promise.resolve({ fileId: FILE_ID }) } -function callGet(query = `workspaceId=${WORKSPACE_ID}`) { - return GET( - new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share?${query}`, { - headers: { 'x-api-key': 'key' }, - }), - context - ) -} - function callPut(body: unknown) { return PUT( new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share`, { @@ -118,77 +110,15 @@ function callPut(body: unknown) { ) } -describe('GET /api/v2/files/[fileId]/share', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) - mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) - mocks.gate.mockResolvedValue(null) - mocks.getShare.mockResolvedValue({ share: SHARE }) - }) - - it('authenticates and rate-limits before parsing or executing', async () => { - mocks.authenticate.mockRejectedValueOnce( - new MockV2ApiKeyUnauthenticatedError('API key required') - ) - - const response = await callGet() - - expect(response.status).toBe(401) - expect(mocks.getShare).not.toHaveBeenCalled() - expect(mocks.operationRate).not.toHaveBeenCalled() - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2Error } = await import('@/app/api/v2/lib/response') - mocks.gate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const response = await callGet() - - expect(response.status).toBe(404) - expect(mocks.getShare).not.toHaveBeenCalled() - }) - - it('validates the asserted workspace before executing the use case', async () => { - const response = await callGet('') - - expect(response.status).toBe(400) - expect(mocks.getShare).not.toHaveBeenCalled() - }) - - it('conceals authorization failures as not found', async () => { - mocks.getShare.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) - - const response = await callGet() - const body = await response.json() - - expect(response.status).toBe(404) - expect(body.error.code).toBe('NOT_FOUND') - expect(mocks.getShare).toHaveBeenCalledWith({ - principal: PRINCIPAL, - input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, - request: expect.anything(), - }) - }) - - it('returns the share through the v2 envelope', async () => { - const response = await callGet() - - expect(response.status).toBe(200) - expect((await response.json()).data).toEqual({ share: SHARE }) - }) - - it('returns the rate-limit response when denied', async () => { - mocks.operationRate.mockResolvedValueOnce({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) - - const response = await callGet() - - expect(response.status).toBe(429) - expect((await response.json()).error.code).toBe('RATE_LIMITED') - expect(mocks.getShare).not.toHaveBeenCalled() - }) -}) +function callDelete(query = `workspaceId=${WORKSPACE_ID}`) { + return DELETE( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share?${query}`, { + method: 'DELETE', + headers: { 'x-api-key': 'key' }, + }), + context + ) +} describe('PUT /api/v2/files/[fileId]/share', () => { beforeEach(() => { @@ -203,7 +133,6 @@ describe('PUT /api/v2/files/[fileId]/share', () => { it('rejects a caller-supplied token at the v2 boundary', async () => { const response = await callPut({ workspaceId: WORKSPACE_ID, - isActive: true, token: 'attacker-chosen-token', }) @@ -217,7 +146,7 @@ describe('PUT /api/v2/files/[fileId]/share', () => { new OrchestrationError('validation', 'Password is required for password-protected shares') ) - const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) + const response = await callPut({ workspaceId: WORKSPACE_ID }) const body = await response.json() expect(response.status).toBe(400) @@ -230,13 +159,20 @@ describe('PUT /api/v2/files/[fileId]/share', () => { it('passes the shared principal and canonical workspace assertion to the use case', async () => { const response = await callPut({ workspaceId: WORKSPACE_ID, - isActive: true, authType: 'password', password: 'hunter2hunter2', }) expect(response.status).toBe(200) - expect((await response.json()).data).toEqual({ share: SHARE }) + expect((await response.json()).data).toEqual({ + sharing: { + enabled: true, + url: SHARE.url, + authType: 'public', + hasPassword: false, + allowedEmails: [], + }, + }) expect(mocks.updateShare).toHaveBeenCalledWith({ principal: PRINCIPAL, input: { @@ -252,9 +188,9 @@ describe('PUT /api/v2/files/[fileId]/share', () => { }) it('conceals forbidden updates as not found', async () => { - mocks.updateShare.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) + mocks.updateShare.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) - const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) + const response = await callPut({ workspaceId: WORKSPACE_ID }) expect(response.status).toBe(404) expect((await response.json()).error.code).toBe('NOT_FOUND') @@ -263,10 +199,49 @@ describe('PUT /api/v2/files/[fileId]/share', () => { it('returns the rate-limit response when denied', async () => { mocks.operationRate.mockResolvedValueOnce({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) - const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) + const response = await callPut({ workspaceId: WORKSPACE_ID }) expect(response.status).toBe(429) expect((await response.json()).error.code).toBe('RATE_LIMITED') expect(mocks.updateShare).not.toHaveBeenCalled() }) }) + +describe('DELETE /api/v2/files/[fileId]/share', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.unshare.mockResolvedValue({ share: { ...SHARE, isActive: false }, changed: true }) + }) + + it('disables sharing through the canonical workspace assertion', async () => { + const response = await callDelete() + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ sharing: { enabled: false } }) + expect(mocks.unshare).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), + }) + }) + + it('validates the workspace before executing', async () => { + const response = await callDelete('') + + expect(response.status).toBe(400) + expect(mocks.unshare).not.toHaveBeenCalled() + }) + + it('returns disabled when the file was already unshared', async () => { + mocks.unshare.mockResolvedValueOnce({ share: null, changed: false }) + + const response = await callDelete() + + expect(response.status).toBe(200) + expect((await response.json()).data.sharing).toEqual({ enabled: false }) + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.ts index 5b6e5a6a015..a0c910e2b85 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.ts @@ -1,31 +1,18 @@ -import { v2GetFileShareContract, v2UpsertFileShareContract } from '@/lib/api/contracts/v2/files' +import { v2ShareFileContract, v2UnshareFileContract } from '@/lib/api/contracts/v2/files' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { - getWorkspaceFileShare, + unshareWorkspaceFile, updateWorkspaceFileShare, } from '@/lib/workspace-files/application/share-workspace-file' +import { toV2DisabledFileSharing, toV2EnabledFileSharing } from '@/app/api/v2/files/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = defineV2JsonRoute({ - contract: v2GetFileShareContract, - auth: v2ApiKeyAuth, - operation: fileOperations.readShare, - rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, - mapInput: ({ params, query }) => ({ - fileId: params.fileId, - assertedWorkspaceId: query.workspaceId, - }), - useCase: getWorkspaceFileShare, - present: ({ share }) => ({ data: { share } }), -}) - export const PUT = defineV2JsonRoute({ - contract: v2UpsertFileShareContract, + contract: v2ShareFileContract, auth: v2ApiKeyAuth, operation: fileOperations.updateShare, rateLimit: v2RateLimits.publicApi, @@ -33,11 +20,25 @@ export const PUT = defineV2JsonRoute({ mapInput: ({ params, body }) => ({ fileId: params.fileId, assertedWorkspaceId: body.workspaceId, - isActive: body.isActive, + isActive: true, authType: body.authType, password: body.password, allowedEmails: body.allowedEmails, }), useCase: updateWorkspaceFileShare, - present: ({ share }) => ({ data: { share } }), + present: ({ share }) => ({ data: { sharing: toV2EnabledFileSharing(share) } }), +}) + +export const DELETE = defineV2JsonRoute({ + contract: v2UnshareFileContract, + auth: v2ApiKeyAuth, + operation: fileOperations.updateShare, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: unshareWorkspaceFile, + present: ({ share }) => ({ data: { sharing: toV2DisabledFileSharing(share) } }), }) diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts index 2e11d23f03d..1e8006082aa 100644 --- a/apps/sim/app/api/v2/files/utils.ts +++ b/apps/sim/app/api/v2/files/utils.ts @@ -1,4 +1,10 @@ -import type { V2File } from '@/lib/api/contracts/v2/files' +import type { ShareRecord } from '@/lib/api/contracts/public-shares' +import type { + V2DisabledFileSharing, + V2EnabledFileSharing, + V2File, + V2FileSharing, +} from '@/lib/api/contracts/v2/files' import { buildFolderPath } from '@/lib/folders/paths' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getUserEmailsByIds, requireResolvedUserEmail } from '@/lib/users/queries' @@ -45,3 +51,29 @@ export async function toV2Files(records: WorkspaceFileRecord[]): Promise ({ + mockApprove: vi.fn(), + mockUseWorkspaces: vi.fn(), + mockPush: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('nuqs', () => ({ + useQueryStates: () => [ + { + request: 'a'.repeat(43), + challenge: 'b'.repeat(43), + pairing: 'ABCD-2345', + scope: 'platform', + workspace: null, + }, + ], +})) + +vi.mock('@/hooks/queries/cli-auth', () => ({ + useApproveCliAuth: () => ({ + mutate: mockApprove, + isPending: false, + isSuccess: false, + isError: false, + error: null, + }), +})) + +vi.mock('@/hooks/queries/workspace', () => ({ + useWorkspacesWithMetadata: mockUseWorkspaces, +})) + +import { CliAuthView } from '@/app/cli/auth/cli-auth-view' + +let container: HTMLDivElement +let root: Root + +function render() { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root.render() + }) +} + +/** The primary CTA is the only button whose label mentions connecting. */ +function connectButton(): HTMLButtonElement { + const buttons = [...container.querySelectorAll('button')] as HTMLButtonElement[] + const button = buttons.find((b) => /connect/i.test(b.textContent ?? '')) + if (!button) throw new Error('Connect button not found') + return button +} + +const LOADED = { + isPending: false, + isError: false, + data: { + workspaces: [ + { id: 'ws_admin', name: 'Acme', permissions: 'admin' }, + { id: 'ws_member', name: 'Other', permissions: 'write' }, + ], + lastActiveWorkspaceId: 'ws_admin', + }, +} + +describe('CliAuthView workspace loading', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('blocks Connect until the workspace list resolves', () => { + // The regression: while pending, the picker falls back to no default, so an + // early click saved no workspace when the same click a moment later would + // have saved the user's last active workspace. + mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) + render() + + expect(connectButton().disabled).toBe(true) + expect(container.textContent).toContain('Loading workspaces') + expect(container.textContent).not.toContain('No default workspace') + }) + + it('does not present a workspace choice as final while loading', () => { + mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) + render() + + expect(container.textContent).toContain('Loading your workspace options') + expect(container.textContent).not.toContain('Issues a personal key') + }) + + it('enables Connect and preselects the last active workspace once loaded', () => { + mockUseWorkspaces.mockReturnValue(LOADED) + render() + + expect(connectButton().disabled).toBe(false) + expect(container.textContent).toContain('Acme') + expect(container.textContent).toContain('personal key') + expect(container.textContent).toContain('makes Acme the CLI default') + }) + + it('issues a personal key even when the approver is a workspace admin', () => { + mockUseWorkspaces.mockReturnValue(LOADED) + render() + act(() => { + connectButton().click() + }) + + expect(mockApprove).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'platform', + workspaceId: 'ws_admin', + bindKeyToWorkspace: false, + }), + expect.anything() + ) + }) + + it('still lets the user connect when the workspace list fails', () => { + // A personal key is degraded but usable; blocking entirely would strand a + // terminal on a transient list failure. + mockUseWorkspaces.mockReturnValue({ isPending: false, isError: true, data: undefined }) + render() + + expect(connectButton().disabled).toBe(false) + expect(container.textContent).toContain('Could not load your workspaces') + }) +}) diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index 96ad0648ffc..f7865af95da 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -1,5 +1,7 @@ 'use client' +import { useMemo, useState } from 'react' +import { ChipSelect, type ChipSelectOption, Label } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' @@ -7,6 +9,10 @@ import { AuthFormMessage, AuthHeader, AuthSubmitButton } from '@/app/(auth)/comp import { resolveCliAuthRequest } from '@/app/cli/auth/cli-auth-request' import { cliAuthParsers } from '@/app/cli/auth/search-params' import { useApproveCliAuth } from '@/hooks/queries/cli-auth' +import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace' + +/** Sentinel for the "no default workspace" row; an empty string reads as unselected. */ +const NO_DEFAULT_WORKSPACE_VALUE = '__no_default_workspace__' /** * The signed-in half of the CLI key handoff: a consent card that records the @@ -22,8 +28,20 @@ export function CliAuthView() { const router = useRouter() const [params] = useQueryStates(cliAuthParsers) const approve = useApproveCliAuth() + const [selected, setSelected] = useState(null) const resolution = resolveCliAuthRequest(params) + const isPlatform = resolution.valid && resolution.request.scope === 'platform' + + const workspaces = useWorkspacesWithMetadata(isPlatform) + + const options = useMemo(() => { + const rows: ChipSelectOption[] = (workspaces.data?.workspaces ?? []).map((workspace) => ({ + label: workspace.name, + value: workspace.id, + })) + return [...rows, { label: 'No default workspace', value: NO_DEFAULT_WORKSPACE_VALUE }] + }, [workspaces.data]) if (!resolution.valid) { return ( @@ -41,6 +59,34 @@ export function CliAuthView() { const { request } = resolution + /** + * Approval must wait for the workspace list. + * + * Until it arrives there is no selection to show, and the fallback would read + * as "No default workspace" — a real answer, not a pending one. Leaving + * Connect live through that window let a fast click save no default when a + * moment later the same click would have saved the user's workspace. Blocking + * is the only way the card can promise what it is about to configure. + */ + const loadingWorkspaces = isPlatform && workspaces.isPending + + /** + * The terminal's suggestion, then the user's last active workspace. Derived at + * render rather than synced into state through an effect, so the first paint + * after the list loads already shows the right row. + * + * The suggestion only counts when it resolves to a workspace the user + * actually has. It comes from a profile the CLI wrote earlier, so it can name + * a workspace they have since left or one that no longer exists — and being + * merely truthy, it used to shadow the last-active fallback and leave the card + * on "no workspace" with a perfectly good one available. + */ + const suggested = workspaces.data?.workspaces.some((w) => w.id === request.suggestedWorkspaceId) + ? request.suggestedWorkspaceId + : null + const workspaceId = selected ?? suggested ?? workspaces.data?.lastActiveWorkspaceId ?? null + const chosen = workspaces.data?.workspaces.find((w) => w.id === workspaceId) + return (
+ {isPlatform && ( +
+ + 8} + searchPlaceholder='Search workspaces' + fullWidth + dropdownWidth='trigger' + /> +

+ {loadingWorkspaces + ? 'Loading your workspace options…' + : workspaces.isError + ? 'Could not load your workspaces. Connecting still works and issues a personal key; reload to pick a default workspace.' + : chosen + ? `Issues a personal key tied to your account and makes ${chosen.name} the CLI default.` + : // No workspace picked, so none is sent and none becomes the + // profile default — promising one here would describe a + // grant that Connect is not about to make. + 'Issues a personal key tied to your account, with no default workspace.'} +

+
+ )} approve.mutate( - { request: request.request, challenge: request.challenge }, + { + request: request.request, + challenge: request.challenge, + scope: request.scope, + // The picked workspace is only the terminal's default. Login + // always mints a personal key so the profile can switch workspaces. + ...(isPlatform && chosen ? { workspaceId: chosen.id } : {}), + bindKeyToWorkspace: false, + }, { onSuccess: () => router.push('/cli/auth/done') } ) } diff --git a/apps/sim/app/cli/auth/page.tsx b/apps/sim/app/cli/auth/page.tsx index 2bc0d86370a..abc058f7704 100644 --- a/apps/sim/app/cli/auth/page.tsx +++ b/apps/sim/app/cli/auth/page.tsx @@ -48,7 +48,11 @@ export default async function CliAuthPage({ request: resolution.request.request, challenge: resolution.request.challenge, pairing: resolution.request.pairing, + scope: resolution.request.scope, }) + if (resolution.request.suggestedWorkspaceId) { + query.set('workspace', resolution.request.suggestedWorkspaceId) + } redirect(`/signup?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`) } diff --git a/apps/sim/app/cli/auth/search-params.ts b/apps/sim/app/cli/auth/search-params.ts index e62b286e594..375a1c77ab3 100644 --- a/apps/sim/app/cli/auth/search-params.ts +++ b/apps/sim/app/cli/auth/search-params.ts @@ -1,16 +1,27 @@ -import { createSearchParamsCache, parseAsString } from 'nuqs/server' +import { createSearchParamsCache, parseAsString, parseAsStringLiteral } from 'nuqs/server' + +/** Key spaces the handoff can mint from. Mirrors `cliAuthScopeSchema`. */ +export const CLI_AUTH_SCOPES = ['copilot', 'platform'] as const /** * Co-located, typed URL query params for the CLI key handoff. Read-only for the * life of the page, so there is no `urlKeys` companion. * - * Nullable with no defaults: a missing value is an invalid request, not a state - * to fall back from. `resolveCliAuthRequest` validates them; never trusted as-is. + * `request`/`challenge`/`pairing` are nullable with no defaults: a missing value + * is an invalid request, not a state to fall back from. `resolveCliAuthRequest` + * validates them; never trusted as-is. + * + * `scope` defaults to `copilot` so a terminal built against the original handoff + * — which sent no scope — still lands on the key space it expects. `workspace` + * is only a preselection hint for the picker; the workspace that ends up bound + * to the key is the one the user confirms, and it is re-authorized server-side. */ export const cliAuthParsers = { request: parseAsString, challenge: parseAsString, pairing: parseAsString, + scope: parseAsStringLiteral(CLI_AUTH_SCOPES).withDefault('copilot'), + workspace: parseAsString, } as const /** diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx index 3dde6afcb0f..7a4647f8bfd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx @@ -11,15 +11,11 @@ import { useState, } from 'react' import { - type DesktopAppearanceTheme, type DesktopZoomAction, type DesktopZoomPercent, resolveDesktopZoom, - TERMINAL_DARK_THEME, - TERMINAL_LIGHT_THEME, type TerminalAppearanceTheme, type TerminalShortcutCommand, - type TerminalThemePalette, type TerminalThemeProfile, } from '@sim/desktop-bridge' import { @@ -45,7 +41,8 @@ import { getDesktopBridge } from '@/lib/desktop' import { loadDesktopTerminalAppearance, loadDesktopTerminalThemeProfiles, - resolveDesktopAppearanceTheme, + refreshSelectedTerminalProfile, + resolveTerminalThemePalette, withSelectedProfile, } from '@/lib/desktop/appearance' import { trackPanelFocus } from '@/lib/desktop/panel-focus' @@ -314,15 +311,7 @@ const TerminalView = memo(function TerminalView({ defaultZoom: DesktopZoomPercent }) { const { resolvedTheme } = useTheme() - const profileTheme = typeof appearanceTheme === 'string' ? undefined : appearanceTheme - const builtInTheme: DesktopAppearanceTheme = - typeof appearanceTheme === 'string' ? appearanceTheme : 'app' - const colorScheme = resolveDesktopAppearanceTheme(builtInTheme, resolvedTheme) - const terminalTheme: TerminalThemePalette = profileTheme - ? profileTheme.palette - : colorScheme === 'dark' - ? TERMINAL_DARK_THEME - : TERMINAL_LIGHT_THEME + const terminalTheme = resolveTerminalThemePalette(appearanceTheme, resolvedTheme) const hostRef = useRef(null) const terminalRef = useRef(null) const fitRef = useRef(null) @@ -756,26 +745,20 @@ export function TerminalSession({ visible, scopeId }: TerminalSessionProps) { ) useEffect(() => { + if (!visible) return let active = true - void loadDesktopTerminalAppearance().then((next) => { - if (!active) return - setAppearanceTheme(next.theme) - setDefaultZoom(next.defaultZoom) - }) - return () => { - active = false - } - }, []) - - useEffect(() => { - let active = true - void loadDesktopTerminalThemeProfiles().then((next) => { - if (active) setProfiles(next) - }) + void Promise.all([loadDesktopTerminalAppearance(), loadDesktopTerminalThemeProfiles()]).then( + ([nextAppearance, nextProfiles]) => { + if (!active) return + setProfiles(nextProfiles) + setAppearanceTheme(refreshSelectedTerminalProfile(nextProfiles, nextAppearance.theme)) + setDefaultZoom(nextAppearance.defaultZoom) + } + ) return () => { active = false } - }, []) + }, [visible]) useEffect(() => { let active = true diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 5c7684320dd..7faf2135da8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -1,6 +1,6 @@ 'use client' -import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react' +import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, PlayOutline, Skeleton, Tooltip, toast } from '@sim/emcn' import { Download, @@ -24,6 +24,7 @@ import { reportManualRunToolStop, } from '@/lib/copilot/tools/client/run-tool-execution' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { prefersInPlaceNavigation } from '@/lib/desktop' import { triggerFileDownload } from '@/lib/uploads/client/download' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { @@ -73,6 +74,25 @@ const LOADING_SKELETON = ( ) +/** + * Opens an internal app link the way the host expects: a new browser tab on the + * web, and the current view in the desktop app, whose shell would otherwise turn + * the same-origin `window.open` into a second Sim window. + */ +function useOpenInternalLink() { + const router = useRouter() + return useCallback( + (href: string) => { + if (prefersInPlaceNavigation()) { + router.push(href) + return + } + window.open(href, '_blank') + }, + [router] + ) +} + interface ResourceContentProps { workspaceId: string desktopScopeId: string @@ -350,6 +370,7 @@ interface EmbeddedWorkflowActionsProps { } export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWorkflowActionsProps) { + const openInternalLink = useOpenInternalLink() const { navigateToSettings } = useSettingsNavigation() const { data: session } = useSession() const hostContext = useWorkspaceHostContext() @@ -404,7 +425,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor } const handleOpenWorkflow = () => { - window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank') + openInternalLink(`/workspace/${workspaceId}/w/${workflowId}`) } return ( @@ -727,6 +748,7 @@ interface EmbeddedFolderProps { } function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) { + const openInternalLink = useOpenInternalLink() const { data: folderList, isPending: isFoldersPending } = useFolders(workspaceId) const { data: workflowList = [] } = useWorkflows(workspaceId) @@ -760,7 +782,7 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {